vanilla-builder 0.0.0-stage → 0.2.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 ADDED
@@ -0,0 +1,1385 @@
1
+ // Orquestador: descubre dependencias, procesa cada tipo de archivo, verifica y publica la salida.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createHeavy, defaultCacheDir, autoWorkers } from './pool.js';
5
+ import { gzipOf, brotliOf, mapLimit } from './compress.js';
6
+ import { scanProject, matchFiles, globMatcher } from './files.js';
7
+ import { resolveRef, classifyUrl, parseSrcset } from './urls.js';
8
+ import { analyzeJs } from './jsinfo.js';
9
+ import { parseHtml, adjacentSiblings, applyEdits, attrText, escapeInlineScript, escapeInlineStyle, minifyHtml, detectPreWhitespace } from './html.js';
10
+ import { buildCss, minifyStyleAttr } from './css.js';
11
+ import {
12
+ processClassic,
13
+ processModuleStandalone,
14
+ buildModuleGroup,
15
+ buildModuleWorker,
16
+ obfuscateModule,
17
+ ensureTerser,
18
+ terserPass,
19
+ levelOf,
20
+ } from './js.js';
21
+ import { resolveObfuscatorOptions, ensureObfuscator } from './obfuscate.js';
22
+ import { detectDependency, dependencyPragma, packageNames } from './deps.js';
23
+ import { assertSafeOutDir, writeStaging, swapIntoPlace, discardStaging, writeManifest, MANIFEST } from './output.js';
24
+ import { verifyOutput } from './verify.js';
25
+ import { rewriteTsImports } from './jsimports.js';
26
+ import { buildReport } from './report.js';
27
+ import {
28
+ BuildError,
29
+ decodeUtf8,
30
+ stripBom,
31
+ BOM,
32
+ extOf,
33
+ JS_EXT,
34
+ TS_EXT,
35
+ jsOutPath,
36
+ CSS_EXT,
37
+ HTML_EXT,
38
+ shortHash,
39
+ sri,
40
+ encodePath,
41
+ minifyJsonText,
42
+ hasOwn,
43
+ } from './util.js';
44
+
45
+ export const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
46
+
47
+ const MERGE_ATTRS = new Set(['src', 'type', 'defer', 'charset', 'language', 'data-builder']);
48
+ const typeOfExt = (ext) =>
49
+ HTML_EXT.has(ext)
50
+ ? 'html'
51
+ : JS_EXT.has(ext)
52
+ ? 'js'
53
+ : CSS_EXT.has(ext)
54
+ ? 'css'
55
+ : ext === '.json' || ext === '.webmanifest'
56
+ ? 'json'
57
+ : ext === '.svg'
58
+ ? 'svg'
59
+ : ['.txt', '.xml', '.md', '.csv'].includes(ext)
60
+ ? 'text'
61
+ : 'asset';
62
+
63
+ class Context {
64
+ constructor(cfg, opts) {
65
+ this.cfg = cfg;
66
+ this.opts = opts;
67
+ this.root = cfg.root;
68
+ const { files, ignored } = scanProject(cfg);
69
+ this.files = files;
70
+ this.ignored = ignored;
71
+ this.ignoredSet = new Set(ignored.map((i) => i.rel));
72
+ this.lowerMap = new Map([...files.keys()].map((k) => [k.toLowerCase(), k]));
73
+ this.base = cfg.base;
74
+ this.warnings = [];
75
+ this.notes = [];
76
+ this.broken = [];
77
+ this.brokenKeys = new Set();
78
+ this.brokenValues = new Set();
79
+ this.assets = new Map();
80
+ this.standalone = new Map();
81
+ this.workers = new Map();
82
+ this.mentions = new Map();
83
+ this.coverage = new Map();
84
+ this.outputs = new Map();
85
+ this.outLower = new Map();
86
+ this.textCache = new Map();
87
+ this.infoCache = new Map();
88
+ this.pageDirs = new Set(['']);
89
+ this.pageRefs = new Set();
90
+ this.done = new Set();
91
+ this.obfuscatorOptions = opts.mode === 'analyze' ? null : resolveObfuscatorOptions(cfg.obfuscate);
92
+ this.depIncluded = globMatcher(cfg.dependencies.include);
93
+ this.depExcluded = globMatcher(cfg.dependencies.exclude);
94
+ this.depCache = new Map();
95
+ this.depMarks = new Map();
96
+ this.tsCode = new Map();
97
+ this.pkgNames = null;
98
+ this.vendorPackages = [];
99
+ this.chunkDir = cfg.js.chunkDir.replace(/\/+$/, '');
100
+ }
101
+
102
+ abs(rel) {
103
+ return path.join(this.root, rel);
104
+ }
105
+
106
+ resolve(spec, baseDir, opts) {
107
+ return resolveRef(spec, baseDir, this, opts);
108
+ }
109
+
110
+ /** Intenta resolver una ruta escrita como texto contra varias carpetas posibles. */
111
+ resolveLoose(spec, dirs) {
112
+ const c = classifyUrl(spec);
113
+ if (c.type !== 'root' && c.type !== 'relative') return null;
114
+ for (const d of dirs) {
115
+ const r = this.resolve(spec, d);
116
+ if (r.status === 'ok') return r.rel;
117
+ }
118
+ return null;
119
+ }
120
+
121
+ warn(msg, file) {
122
+ const text = file ? `${file}: ${msg}` : msg;
123
+ if (!this.warnings.includes(text)) this.warnings.push(text);
124
+ }
125
+
126
+ note(msg, file) {
127
+ const text = file ? `${file}: ${msg}` : msg;
128
+ if (!this.notes.includes(text)) this.notes.push(text);
129
+ }
130
+
131
+ readText(rel, { kind = 'text', charset } = {}) {
132
+ // TypeScript: como JS se lee la versión ya transpilada (sin tipos).
133
+ if (kind === 'js' && this.tsCode.has(rel)) return { text: this.tsCode.get(rel), bom: false, encoding: 'utf8' };
134
+ const key = `${rel}\0${kind}`;
135
+ if (this.textCache.has(key)) return this.textCache.get(key);
136
+ const buf = fs.readFileSync(this.abs(rel));
137
+ let text = decodeUtf8(buf);
138
+ let encoding = 'utf8';
139
+ if (text == null) {
140
+ if (kind === 'html') {
141
+ // latin1 conserva cada byte tal cual: lo que no se edita sale idéntico.
142
+ text = buf.toString('latin1');
143
+ encoding = 'latin1';
144
+ this.warn('no es UTF-8; se conserva su codificación original y no se minifica', rel);
145
+ } else {
146
+ let label = (charset || 'windows-1252').toLowerCase();
147
+ try {
148
+ text = new TextDecoder(label).decode(buf);
149
+ } catch {
150
+ label = 'windows-1252';
151
+ text = new TextDecoder(label).decode(buf);
152
+ }
153
+ encoding = label;
154
+ this.note(`no es UTF-8; se leyó como ${label} y la salida usa escapes ASCII (no depende de la codificación)`, rel);
155
+ }
156
+ }
157
+ const stripped = stripBom(text);
158
+ const res = { text: stripped.text, bom: stripped.bom, encoding };
159
+ this.textCache.set(key, res);
160
+ return res;
161
+ }
162
+
163
+ jsInfo(rel, module = 'auto') {
164
+ const key = `${rel}\0${module}`;
165
+ if (!this.infoCache.has(key)) this.infoCache.set(key, analyzeJs(this.readText(rel, { kind: 'js' }).text, { module }));
166
+ return this.infoCache.get(key);
167
+ }
168
+
169
+ reportBroken(from, url, r) {
170
+ const key = `${from}\0${url}`;
171
+ this.brokenValues.add(url);
172
+ if (this.brokenKeys.has(key)) return;
173
+ this.brokenKeys.add(key);
174
+ this.broken.push({ from, url, status: r.status, target: r.rel });
175
+ if (r.status === 'ignored') this.warn(`referencia a "${url}", pero ese archivo está excluido (ignore). No se publicará.`, from);
176
+ else if (r.status === 'case') this.warn(`"${url}" solo existe como "${r.rel}" (otras mayúsculas): en un servidor Linux daría 404.`, from);
177
+ else if (r.status === 'missing') this.warn(`"${url}" no existe (ya estaba rota en el código fuente).`, from);
178
+ else if (r.status === 'outside') this.note(`"${url}" apunta fuera del proyecto; se deja igual.`, from);
179
+ }
180
+
181
+ registerAsset(rel, from) {
182
+ if (!this.assets.has(rel)) this.assets.set(rel, new Set());
183
+ if (from) this.assets.get(rel).add(from);
184
+ const ext = extOf(rel);
185
+ if (JS_EXT.has(ext) || CSS_EXT.has(ext)) this.requireStandalone(rel, `referenciado desde ${from}`);
186
+ }
187
+
188
+ assetOut(rel) {
189
+ return rel;
190
+ }
191
+
192
+ /** Destino de new URL('...', import.meta.url): siempre la ruta original del archivo. */
193
+ urlTarget(rel, from) {
194
+ const ext = extOf(rel);
195
+ if (JS_EXT.has(ext) || CSS_EXT.has(ext)) {
196
+ if (!this.workers.has(rel)) this.requireStandalone(rel, `new URL() en ${from}`);
197
+ } else this.registerAsset(rel, from);
198
+ return jsOutPath(rel);
199
+ }
200
+
201
+ requireStandalone(rel, reason, extra = {}) {
202
+ if (!this.standalone.has(rel)) this.standalone.set(rel, { reason, ...extra });
203
+ else if (extra.module) this.standalone.get(rel).module = true;
204
+ }
205
+
206
+ registerWorker(rel, info) {
207
+ const prev = this.workers.get(rel);
208
+ if (prev) {
209
+ 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);
210
+ prev.module = prev.module || info.module;
211
+ return;
212
+ }
213
+ this.workers.set(rel, info);
214
+ }
215
+
216
+ /** Registra workers, service workers, importScripts y rutas mencionadas como texto. */
217
+ noteJsInfo(id, info, baseDir) {
218
+ for (const w of info.workers) {
219
+ if (!w.spec) {
220
+ this.warn(`${w.kind} con una ruta que no es texto fijo; Builder no puede seguirla (el archivo se procesará suelto si existe).`, id);
221
+ continue;
222
+ }
223
+ const c = classifyUrl(w.spec);
224
+ if (c.type !== 'root' && c.type !== 'relative') continue;
225
+ const dirs = w.viaUrl || w.kind === 'importScripts' ? [baseDir] : [baseDir, ...this.pageDirs];
226
+ const rel = this.resolveLoose(w.spec, dirs);
227
+ if (!rel) {
228
+ this.reportBroken(id, w.spec, this.resolve(w.spec, baseDir));
229
+ continue;
230
+ }
231
+ if (w.kind === 'importScripts') this.requireStandalone(rel, `importScripts en ${id}`, { classic: true });
232
+ else this.registerWorker(rel, { module: w.module, kind: w.kind, from: id });
233
+ }
234
+ for (const s of info.strings) {
235
+ const rel = this.resolveLoose(s, [baseDir, ...this.pageDirs]);
236
+ if (rel && !this.mentions.has(rel)) this.mentions.set(rel, id);
237
+ }
238
+ }
239
+
240
+ cover(rel, status, out) {
241
+ if (!this.files.has(rel)) return;
242
+ if (!this.coverage.has(rel)) this.coverage.set(rel, []);
243
+ this.coverage.get(rel).push(out ? `${status} → ${out}` : status);
244
+ }
245
+
246
+ hasOwnOutput(rel) {
247
+ const o = this.outputs.get(rel) ?? (TS_EXT.has(extOf(rel)) ? this.outputs.get(jsOutPath(rel)) : undefined);
248
+ return !!o && o.sources?.includes(rel);
249
+ }
250
+
251
+ /** ¿Es una librería de terceros? (a mano en dependencies.include/exclude, o detectada). */
252
+ // Prioridad: configuración (include/exclude) > marca en el HTML o en el archivo > detección automática.
253
+ dependencyReason(rel) {
254
+ if (this.depCache.has(rel)) return this.depCache.get(rel);
255
+ const deps = this.cfg.dependencies;
256
+ const isJs = JS_EXT.has(extOf(rel)) && this.files.has(rel);
257
+ const text = isJs ? this.readText(rel, { kind: 'js' }).text : '';
258
+ const pragma = isJs ? dependencyPragma(text) : null;
259
+ const mark = this.depMarks.get(rel);
260
+ let reason = null;
261
+ if (this.depExcluded(rel)) reason = null;
262
+ else if (this.depIncluded(rel)) reason = 'marcado a mano (dependencies.include)';
263
+ else if (mark === 'own' || pragma === 'own') reason = null;
264
+ else if (mark === 'dependency') reason = 'marcado en el HTML (data-builder="dependency")';
265
+ else if (pragma === 'dependency') reason = 'marcado en el propio archivo (@builder-dependency)';
266
+ else if (/(^|\/)node_modules\//.test(rel)) reason = 'paquete de npm';
267
+ else if (deps.detect && isJs) reason = detectDependency(rel, text, { packages: this.packageHints() });
268
+ this.depCache.set(rel, reason);
269
+ return reason;
270
+ }
271
+
272
+ /** Paquetes del package.json más cercano (para reconocer librerías copiadas a mano). */
273
+ packageHints() {
274
+ if (this.pkgNames) return this.pkgNames;
275
+ this.pkgNames = new Map();
276
+ for (let dir = this.root, i = 0; i < 10; i++) {
277
+ const file = path.join(dir, 'package.json');
278
+ if (fs.existsSync(file)) {
279
+ try {
280
+ this.pkgNames = packageNames(JSON.parse(fs.readFileSync(file, 'utf8')));
281
+ } catch {
282
+ // package.json ilegible: no aporta pistas
283
+ }
284
+ break;
285
+ }
286
+ const parent = path.dirname(dir);
287
+ if (parent === dir) break;
288
+ dir = parent;
289
+ }
290
+ return this.pkgNames;
291
+ }
292
+
293
+ shouldObfuscate(rels) {
294
+ if (!this.obfuscatorOptions) return false;
295
+ const list = rels.filter(Boolean);
296
+ if (!list.length) return false;
297
+ if (this.cfg.dependencies.obfuscate) return true;
298
+ return !list.every((r) => this.dependencyReason(r));
299
+ }
300
+
301
+ /** Registra un archivo de salida; nunca sobrescribe en silencio un contenido distinto. */
302
+ emit(outRel, data, meta) {
303
+ const norm = path.posix.normalize(outRel);
304
+ if (norm.startsWith('../') || path.posix.isAbsolute(norm) || norm === MANIFEST) throw new BuildError(`Ruta de salida inválida: ${outRel}`);
305
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, meta.encoding === 'latin1' ? 'latin1' : 'utf8');
306
+ const prev = this.outputs.get(norm);
307
+ if (prev) {
308
+ const same = prev.copyFrom ? fs.readFileSync(prev.copyFrom).equals(buf) : prev.data.equals(buf);
309
+ if (!same)
310
+ throw new BuildError(`Dos resultados distintos quieren escribirse en "${norm}".`, [
311
+ `Primero: ${(prev.sources ?? []).join(', ') || prev.action}`,
312
+ `Después: ${(meta.sources ?? []).join(', ') || meta.action}`,
313
+ 'Suele pasar si el mismo archivo se usa con import maps distintos o si una carpeta del proyecto se llama igual que js.chunkDir.',
314
+ ]);
315
+ prev.sources = [...new Set([...(prev.sources ?? []), ...(meta.sources ?? [])])];
316
+ return norm;
317
+ }
318
+ const lower = norm.toLowerCase();
319
+ if (this.outLower.has(lower) && this.outLower.get(lower) !== norm)
320
+ this.warn(`"${norm}" y "${this.outLower.get(lower)}" solo se diferencian en mayúsculas; en Windows/macOS se pisarían.`);
321
+ this.outLower.set(lower, norm);
322
+ this.outputs.set(norm, { ...meta, data: buf });
323
+ return norm;
324
+ }
325
+
326
+ emitCopy(outRel, rel, action = 'copy') {
327
+ const f = this.files.get(rel);
328
+ const prev = this.outputs.get(outRel);
329
+ if (prev) {
330
+ if (prev.sources?.includes(rel)) return outRel;
331
+ return this.emit(outRel, fs.readFileSync(f.abs), { type: typeOfExt(f.ext), action, sources: [rel] });
332
+ }
333
+ this.outputs.set(outRel, { copyFrom: f.abs, size: f.size, type: typeOfExt(f.ext), action, sources: [rel] });
334
+ this.outLower.set(outRel.toLowerCase(), outRel);
335
+ return outRel;
336
+ }
337
+
338
+ /** Añade el source map según la configuración y registra el archivo. */
339
+ emitCode(outRel, code, map, meta) {
340
+ const sm = this.cfg.sourcemap;
341
+ let text = code;
342
+ if (map && sm) {
343
+ const isCss = meta.type === 'css';
344
+ if (sm === 'inline') {
345
+ const url = `data:application/json;base64,${Buffer.from(map).toString('base64')}`;
346
+ text += isCss ? `\n/*# sourceMappingURL=${url} */\n` : `\n//# sourceMappingURL=${url}\n`;
347
+ } else {
348
+ this.emit(`${outRel}.map`, map, { type: 'map', action: 'sourcemap', sources: meta.sources });
349
+ if (sm !== 'hidden') {
350
+ const name = path.posix.basename(outRel);
351
+ text += isCss ? `\n/*# sourceMappingURL=${name}.map */\n` : `\n//# sourceMappingURL=${name}.map\n`;
352
+ }
353
+ }
354
+ }
355
+ return this.emit(outRel, text, meta);
356
+ }
357
+
358
+ hashedPath(rel, data, ext) {
359
+ const name = path.posix.basename(rel, path.posix.extname(rel));
360
+ return `${this.chunkDir}/${name}-${shortHash(data)}${ext}`;
361
+ }
362
+
363
+ outputBytes(outRel) {
364
+ const o = this.outputs.get(outRel);
365
+ return o.copyFrom ? fs.readFileSync(o.copyFrom) : o.data;
366
+ }
367
+ }
368
+
369
+ /** URL para escribir en el HTML, respetando si el original era absoluto (/x) o relativo. */
370
+ function urlFor(ctx, page, originalValue, targetRel) {
371
+ const info = classifyUrl(originalValue);
372
+ const suffix = info.suffix ?? '';
373
+ if (info.type === 'root') return `${ctx.base}${encodePath(targetRel)}${suffix}`;
374
+ let rel = path.posix.relative(page.baseDir || '.', targetRel);
375
+ if (!rel) rel = path.posix.basename(targetRel);
376
+ return encodePath(rel) + suffix;
377
+ }
378
+
379
+ /** Lee y analiza todos los HTML del proyecto. */
380
+ function loadPages(ctx) {
381
+ const pages = new Map();
382
+ for (const [rel, f] of ctx.files) {
383
+ if (!HTML_EXT.has(f.ext)) continue;
384
+ const { text, bom, encoding } = ctx.readText(rel, { kind: 'html' });
385
+ let model;
386
+ try {
387
+ model = parseHtml(text);
388
+ } catch (err) {
389
+ ctx.warn(`no se pudo analizar el HTML (${err.message}); se copia tal cual`, rel);
390
+ continue;
391
+ }
392
+ const dir = path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel);
393
+ const page = { rel, text, bom, encoding, model, dir, baseDir: dir, baseExternal: false, full: false, edits: [] };
394
+ if (model.base != null) {
395
+ const c = classifyUrl(model.base);
396
+ if (c.type === 'external') {
397
+ page.baseExternal = true;
398
+ ctx.warn(`<base href="${model.base}"> apunta fuera del sitio; sus rutas relativas no se pueden comprobar y se dejan igual.`, rel);
399
+ } else if (c.type === 'root' || c.type === 'relative') {
400
+ 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);
401
+ page.baseDir = path.posix
402
+ .normalize(p.endsWith('/') ? p : path.posix.dirname(p))
403
+ .replace(/^\.$/, '')
404
+ .replace(/\/$/, '');
405
+ }
406
+ }
407
+ pages.set(rel, page);
408
+ }
409
+ return pages;
410
+ }
411
+
412
+ /** Decide qué páginas se procesan completas (entradas + enlazadas) y cuáles en modo conservador. */
413
+ /**
414
+ * Transpila cada archivo TypeScript a JavaScript (solo quita los tipos: no baja el nivel de sintaxis).
415
+ * Usa el tsconfig.json del proyecto si existe. Lo que falle se avisa y sigue el camino normal
416
+ * (se copia sin cambios), así que nunca se pierde nada.
417
+ */
418
+ async function transpileTypeScript(ctx) {
419
+ const list = [...ctx.files.keys()].filter((rel) => TS_EXT.has(extOf(rel)));
420
+ if (!list.length) return;
421
+ let tsconfigRaw;
422
+ const tsconfig = path.join(ctx.root, 'tsconfig.json');
423
+ if (fs.existsSync(tsconfig)) tsconfigRaw = fs.readFileSync(tsconfig, 'utf8');
424
+ const esbuild = await import('esbuild');
425
+ await mapLimit(list, 32, async (rel) => {
426
+ const source = ctx.readText(rel, { kind: 'ts' }).text;
427
+ try {
428
+ const r = await esbuild.transform(source, {
429
+ loader: 'ts',
430
+ sourcefile: rel,
431
+ target: 'esnext',
432
+ tsconfigRaw,
433
+ logLevel: 'silent',
434
+ supported: { 'import-attributes': true },
435
+ });
436
+ const dir = path.posix.dirname(rel);
437
+ // "./x" → la ruta que existirá en la salida (x.ts → x.js, x/index.ts → x/index.js…).
438
+ const complete = (spec) => {
439
+ const base = path.posix.normalize(path.posix.join(dir, spec));
440
+ for (const cand of ['.ts', '.mts', '.js', '.mjs', '/index.ts', '/index.js'].map((s) => base + s)) {
441
+ if (ctx.files.has(cand)) return spec + jsOutPath(cand).slice(base.length);
442
+ }
443
+ return null;
444
+ };
445
+ ctx.tsCode.set(rel, rewriteTsImports(r.code, complete));
446
+ } catch (err) {
447
+ const msg = String(err.errors?.[0]?.text ?? err.message).split('\n')[0];
448
+ ctx.warn(`TypeScript no válido (${msg}); se copia sin transpilar`, rel);
449
+ }
450
+ });
451
+ }
452
+
453
+ /** <script src="..." data-builder="dependency|own">: el usuario marca librerías (o código propio) en el HTML. */
454
+ const MARK_VALUES = { dependency: 'dependency', dependencia: 'dependency', dep: 'dependency', own: 'own', propio: 'own' };
455
+ function markDependencies(ctx, pages) {
456
+ for (const page of pages.values()) {
457
+ for (const s of page.model.scripts) {
458
+ if (!s.src || !s.attrs.has('data-builder')) continue;
459
+ const raw = String(s.attrs.get('data-builder') ?? '')
460
+ .trim()
461
+ .toLowerCase();
462
+ const value = MARK_VALUES[raw];
463
+ if (!value) {
464
+ ctx.warn(`data-builder="${raw}" no se reconoce; usa "dependency" o "own"`, page.rel);
465
+ continue;
466
+ }
467
+ const res = ctx.resolve(s.src.value, page.baseDir);
468
+ if (res.status !== 'ok') continue;
469
+ const prev = ctx.depMarks.get(res.rel);
470
+ if (prev && prev !== value)
471
+ ctx.warn(`"${res.rel}" está marcado como "${prev}" y como "${value}" en distintas páginas; se usa "${prev}"`, page.rel);
472
+ else ctx.depMarks.set(res.rel, value);
473
+ }
474
+ }
475
+ }
476
+
477
+ function selectEntries(ctx, pages) {
478
+ const cfg = ctx.cfg;
479
+ const extraEntries = { modules: [], classic: [], css: [] };
480
+ let queue = [];
481
+ if (cfg.entries === 'auto') {
482
+ queue = [...pages.values()].filter((p) => p.model.isDocument).map((p) => p.rel);
483
+ } else {
484
+ const { matched, missing } = matchFiles(ctx.files, cfg.entries);
485
+ if (missing.length) throw new BuildError('Algunas entradas de "entries" no existen:', missing);
486
+ for (const rel of matched) {
487
+ const ext = extOf(rel);
488
+ if (HTML_EXT.has(ext)) queue.push(rel);
489
+ else if (CSS_EXT.has(ext)) extraEntries.css.push(rel);
490
+ else if (JS_EXT.has(ext)) {
491
+ const info = ctx.jsInfo(rel);
492
+ (info.ok && (info.hasModuleSyntax || info.hasImportMeta) ? extraEntries.modules : extraEntries.classic).push(rel);
493
+ } else ctx.warn(`"${rel}" no es HTML, JS ni CSS; se copiará como recurso`, 'entries');
494
+ }
495
+ }
496
+ const full = new Set();
497
+ while (queue.length) {
498
+ const rel = queue.shift();
499
+ const page = pages.get(rel);
500
+ if (!page || full.has(rel)) continue;
501
+ if (page.encoding === 'latin1' || page.baseExternal) {
502
+ continue; // se procesa en modo conservador
503
+ }
504
+ full.add(rel);
505
+ page.full = true;
506
+ ctx.pageDirs.add(page.baseDir);
507
+ for (const r of page.model.refs) {
508
+ if (r.kind !== 'page') continue;
509
+ const res = ctx.resolve(r.value, page.baseDir, { page: true });
510
+ if (res.status === 'ok' && HTML_EXT.has(extOf(res.rel)) && pages.get(res.rel)?.model.isDocument) queue.push(res.rel);
511
+ }
512
+ }
513
+ return extraEntries;
514
+ }
515
+
516
+ /** Registra los recursos simples (imágenes, iconos, enlaces...) de una página. */
517
+ function collectRefs(ctx, page) {
518
+ for (const r of page.model.refs) {
519
+ const values = r.kind === 'srcset' ? parseSrcset(r.value).map((c) => c.url) : [r.value];
520
+ for (const v of values) {
521
+ const res = ctx.resolve(v, page.baseDir, { page: r.kind === 'page' });
522
+ if (res.status === 'skip') continue;
523
+ if (res.status !== 'ok') {
524
+ if (r.soft) ctx.brokenValues.add(v);
525
+ else ctx.reportBroken(page.rel, v, res);
526
+ continue;
527
+ }
528
+ if (HTML_EXT.has(extOf(res.rel))) {
529
+ ctx.pageRefs.add(res.rel);
530
+ continue;
531
+ }
532
+ ctx.registerAsset(res.rel, page.rel);
533
+ }
534
+ }
535
+ for (const l of page.model.links) {
536
+ if (l.type === 'stylesheet' || l.type === 'preload') continue;
537
+ const res = ctx.resolve(l.href.value, page.baseDir, { page: l.type === 'page' });
538
+ if (res.status === 'ok') {
539
+ if (!HTML_EXT.has(extOf(res.rel))) ctx.registerAsset(res.rel, page.rel);
540
+ } else if (res.status !== 'skip') {
541
+ if (l.type === 'asset') ctx.reportBroken(page.rel, l.href.value, res);
542
+ else ctx.brokenValues.add(l.href.value);
543
+ }
544
+ }
545
+ }
546
+
547
+ function parseImportMap(ctx, page, item) {
548
+ try {
549
+ const json = JSON.parse(item.inline.code);
550
+ return { json, imports: json.imports ?? {}, scopes: json.scopes, baseDir: page.baseDir };
551
+ } catch (err) {
552
+ ctx.warn(`el import map no es JSON válido (${err.message}); se ignora`, page.rel);
553
+ return null;
554
+ }
555
+ }
556
+
557
+ /** Agrupa los scripts clásicos consecutivos que se pueden unir sin cambiar el comportamiento. */
558
+ function classicRuns(ctx, page) {
559
+ const cfg = ctx.cfg;
560
+ const mergeOn = cfg.js.mergeClassic === 'auto' ? !cfg.sourcemap : cfg.js.mergeClassic;
561
+ const runs = [];
562
+ let current = null;
563
+ const close = () => {
564
+ if (current) runs.push(current);
565
+ current = null;
566
+ };
567
+ for (const s of page.model.scripts) {
568
+ if (s.kind !== 'classic' || !s.src) {
569
+ close();
570
+ continue;
571
+ }
572
+ const res = ctx.resolve(s.src.value, page.baseDir);
573
+ if (res.status === 'skip') {
574
+ close();
575
+ continue;
576
+ }
577
+ if (res.status !== 'ok') {
578
+ ctx.reportBroken(page.rel, s.src.value, res);
579
+ close();
580
+ continue;
581
+ }
582
+ const rel = res.rel;
583
+ if (!JS_EXT.has(extOf(rel))) ctx.note(`<script src> apunta a "${rel}" (extensión no habitual); se trata como JavaScript`, page.rel);
584
+ const info = ctx.jsInfo(rel, false);
585
+ const attrs = s.attrs;
586
+ const reasons = [];
587
+ if (!mergeOn) reasons.push('unión desactivada');
588
+ if (!info.ok) reasons.push('no se pudo analizar');
589
+ if (s.inTemplate) reasons.push('dentro de <template>');
590
+ if (attrs.has('async')) reasons.push('async');
591
+ if (info.usesCurrentScript) reasons.push('usa document.currentScript');
592
+ if (info.usesDocumentWrite) reasons.push('usa document.write');
593
+ const extra = [...attrs.keys()].filter((a) => !MERGE_ATTRS.has(a));
594
+ if (extra.length) reasons.push(`atributos ${extra.join(', ')}`);
595
+ const obf = ctx.shouldObfuscate([rel]);
596
+ const key = [attrs.has('defer'), attrs.has('nomodule'), attrs.get('charset') ?? '', info.strict, obf].join('|');
597
+ const cand = { s, rel, info, key, obf, reasons };
598
+ if (reasons.length) {
599
+ close();
600
+ runs.push({ items: [cand], names: null });
601
+ continue;
602
+ }
603
+ if (current && current.key === key && adjacentSiblings(current.items[current.items.length - 1].s, s) && !conflicts(current.names, info.top)) {
604
+ current.items.push(cand);
605
+ for (const [n, k] of info.top) if (!current.names.has(n) || k === 'lex') current.names.set(n, k);
606
+ } else {
607
+ close();
608
+ current = { key, items: [cand], names: new Map(info.top) };
609
+ }
610
+ }
611
+ close();
612
+ return runs;
613
+ }
614
+
615
+ function conflicts(names, top) {
616
+ for (const [n, k] of top) if (names.has(n) && (k === 'lex' || names.get(n) === 'lex')) return true;
617
+ return false;
618
+ }
619
+
620
+ function setIntegrity(ctx, page, item, attrName, bytes) {
621
+ const has = item.attrs.has('integrity');
622
+ const loc = item.el.sourceCodeLocation.attrs?.integrity;
623
+ const mode = ctx.cfg.sri;
624
+ if (has && loc) {
625
+ if (mode === false) page.edits.push({ start: loc.startOffset, end: loc.endOffset, text: '' });
626
+ else page.edits.push({ start: loc.startOffset, end: loc.endOffset, text: attrText('integrity', sri(bytes)) });
627
+ } else if (mode === true) {
628
+ const at = item.el.sourceCodeLocation.attrs?.[attrName];
629
+ if (at) page.edits.push({ start: at.endOffset, end: at.endOffset, text: ` ${attrText('integrity', sri(bytes))}` });
630
+ }
631
+ }
632
+
633
+ /** Ejecuta todo el proceso. mode: 'build' escribe la salida; 'analyze' solo calcula el plan. */
634
+ export async function runPipeline(cfg, opts = {}) {
635
+ const mode = opts.mode ?? 'build';
636
+ const t0 = Date.now();
637
+ if (mode === 'build') assertSafeOutDir(cfg, { force: opts.force });
638
+ const ctx = new Context(cfg, { ...opts, mode });
639
+ const workers = cfg.workers === 'auto' ? autoWorkers() : cfg.workers;
640
+ ctx.heavy = createHeavy({ root: cfg.root, workers, cacheDir: cfg.cache && mode === 'build' ? defaultCacheDir(cfg.root) : null });
641
+ try {
642
+ return await pipeline(ctx, cfg, opts, mode, t0);
643
+ } finally {
644
+ await ctx.heavy.close();
645
+ }
646
+ }
647
+
648
+ async function pipeline(ctx, cfg, opts, mode, t0) {
649
+ const log = opts.log ?? (() => {});
650
+ // Tiempo de cada fase (perf.phases): para saber dónde se va el tiempo en proyectos reales.
651
+ const phases = [];
652
+ let phaseStart = Date.now();
653
+ let phaseName = 'preparación';
654
+ const mark = (next) => {
655
+ const now = Date.now();
656
+ phases.push([phaseName, now - phaseStart]);
657
+ phaseName = next;
658
+ phaseStart = now;
659
+ };
660
+ if (ctx.obfuscatorOptions) await ensureObfuscator(cfg.root);
661
+ if (cfg.js.minifier === 'terser' && cfg.js.minify) await ensureTerser(cfg.root);
662
+
663
+ mark('páginas');
664
+ // 1. Páginas y entradas
665
+ await transpileTypeScript(ctx);
666
+ const pages = loadPages(ctx);
667
+ markDependencies(ctx, pages);
668
+ const extra = selectEntries(ctx, pages);
669
+ const fullPages = [...pages.values()].filter((p) => p.full);
670
+ const partialPages = [...pages.values()].filter((p) => !p.full);
671
+ log(`Páginas: ${fullPages.length} completas, ${partialPages.length} en modo conservador`);
672
+ const htmlBlob = [];
673
+ for (const p of fullPages) collectRefs(ctx, p);
674
+ for (const p of partialPages) collectRefs(ctx, p);
675
+
676
+ mark('módulos');
677
+ // 2. Módulos ES agrupados por import map
678
+ const groups = new Map();
679
+ const groupFor = (page, importMap) => {
680
+ const key = importMap ? `${page.baseDir}\0${JSON.stringify(importMap.json)}` : '';
681
+ if (!groups.has(key))
682
+ groups.set(key, {
683
+ key,
684
+ importMap,
685
+ hash: cfg.hash,
686
+ entries: new Set(),
687
+ virtual: new Map(),
688
+ pages: new Set(),
689
+ forcePerFile: importMap?.scopes ? 'el import map usa "scopes"' : null,
690
+ vendorSplit: !!ctx.obfuscatorOptions && !cfg.dependencies.obfuscate,
691
+ });
692
+ return groups.get(key);
693
+ };
694
+ const moduleRefs = [];
695
+ const preloadLinks = [];
696
+ for (const page of fullPages) {
697
+ const map = page.model.importMaps[0] ? parseImportMap(ctx, page, page.model.importMaps[0]) : null;
698
+ if (page.model.importMaps.length > 1) ctx.warn('tiene varios import maps; solo se usa el primero para empaquetar', page.rel);
699
+ let vIndex = 0;
700
+ for (const s of page.model.scripts) {
701
+ if (s.kind !== 'module' || s.inTemplate) continue;
702
+ const g = groupFor(page, map);
703
+ g.pages.add(page.rel);
704
+ if (s.src) {
705
+ const res = ctx.resolve(s.src.value, page.baseDir);
706
+ if (res.status === 'skip') continue;
707
+ if (res.status !== 'ok') {
708
+ ctx.reportBroken(page.rel, s.src.value, res);
709
+ continue;
710
+ }
711
+ g.entries.add(res.rel);
712
+ moduleRefs.push({ page, s, rel: res.rel, group: g });
713
+ } else if (s.inline && s.inline.code.trim()) {
714
+ const id = `vb-inline:${page.rel}#${vIndex++}`;
715
+ const outDir = page.baseDir ? `${page.baseDir}/` : '';
716
+ g.virtual.set(id, {
717
+ code: s.inline.code,
718
+ label: `${page.rel} (módulo inline ${vIndex})`,
719
+ baseDir: page.baseDir,
720
+ out: `${outDir}__vb_inline_${shortHash(id)}`,
721
+ page,
722
+ s,
723
+ });
724
+ }
725
+ }
726
+ for (const l of page.model.links) if (l.type === 'preload') preloadLinks.push({ page, l });
727
+ }
728
+ if (extra.modules.length) {
729
+ const g = {
730
+ key: '\0entries',
731
+ importMap: null,
732
+ hash: false,
733
+ entries: new Set(extra.modules),
734
+ virtual: new Map(),
735
+ pages: new Set(),
736
+ forcePerFile: null,
737
+ };
738
+ groups.set(g.key, g);
739
+ }
740
+ const entryOut = new Map(); // `${groupKey}\0${rel}` → outRel
741
+ const inlineModuleCode = new Map(); // virtual id → código final
742
+ for (const g of groups.values()) {
743
+ if (!g.entries.size && !g.virtual.size) continue;
744
+ const res = await buildModuleGroup(ctx, { ...g, hash: g.hash });
745
+ if (res.mode === 'per-file') {
746
+ ctx.note(`módulos sin empaquetar (cada archivo minificado en su sitio): ${res.reason}`, [...g.pages].join(', ') || 'entries');
747
+ for (const rel of res.inputs) ctx.requireStandalone(rel, 'módulo (modo sin empaquetar)', { module: true });
748
+ for (const rel of g.entries) entryOut.set(`${g.key}\0${rel}`, jsOutPath(rel));
749
+ for (const [id, v] of g.virtual) {
750
+ const info = analyzeJs(v.code, { module: true });
751
+ if (!info.ok) continue;
752
+ ctx.noteJsInfo(v.label, info, v.baseDir);
753
+ for (const imp of info.imports) if (imp.spec) markModuleImport(ctx, imp.spec, v.baseDir, v.label, g.importMap);
754
+ const r = await processModuleStandalone(ctx, { code: v.code, id: v.label, info, obfuscate: ctx.shouldObfuscate([v.page.rel]) });
755
+ for (const n of r.notes) ctx.note(n, v.label);
756
+ inlineModuleCode.set(id, r.code);
757
+ }
758
+ continue;
759
+ }
760
+ log(`Módulos: ${g.entries.size} entradas, ${res.inputs.length} archivos, target ${res.target}`);
761
+ // Ofuscar/comprimir cada salida en paralelo; registrar después en el orden original (salida determinista).
762
+ const finals = await mapLimit(res.outputs, 64, async (o) => {
763
+ const obfRels = o.virtualId ? [g.virtual.get(o.virtualId).page.rel] : o.isChunk ? o.inputs : [o.entry];
764
+ if (!o.isVendor && ctx.shouldObfuscate(obfRels)) {
765
+ const r = await obfuscateModule(ctx, o.code, o.outRel);
766
+ return r.note ? { code: o.code, map: o.map, warn: r.note } : { code: r.code, map: null };
767
+ }
768
+ const t = await terserPass(ctx, o.code, { isModule: true, level: levelOf(res.target) });
769
+ return t !== o.code ? { code: t, map: null } : { code: o.code, map: o.map };
770
+ });
771
+ for (const [i, o] of res.outputs.entries()) {
772
+ const sources = o.inputs.length ? o.inputs : o.entry ? [o.entry] : [];
773
+ const { code, map, warn } = finals[i];
774
+ if (warn) ctx.warn(warn, o.outRel);
775
+ if (o.virtualId) {
776
+ const v = g.virtual.get(o.virtualId);
777
+ inlineModuleCode.set(o.virtualId, code.replace(/\n$/, ''));
778
+ for (const rel of o.inputs) ctx.cover(rel, 'empaquetado', `${v.page.rel} (módulo inline)`);
779
+ continue;
780
+ }
781
+ const action = o.isVendor ? 'vendor' : o.isChunk ? 'chunk' : 'bundle';
782
+ const outRel = ctx.emitCode(o.outRel, code, map, { type: 'js', module: true, action, sources, before: o.before || sumSizes(ctx, sources) });
783
+ if (o.entry) entryOut.set(`${g.key}\0${o.entry}`, outRel);
784
+ for (const rel of o.inputs) ctx.cover(rel, o.isVendor ? 'empaquetado (dependencia, sin ofuscar)' : 'empaquetado', outRel);
785
+ }
786
+ for (const spec of res.vendor) if (!ctx.vendorPackages.includes(spec)) ctx.vendorPackages.push(spec);
787
+ // esbuild puede incrustar un módulo entero (p. ej. constantes) sin que figure en ninguna salida.
788
+ for (const rel of res.consumed) {
789
+ if (!ctx.coverage.get(rel)?.some((s) => s.startsWith('empaquetado'))) ctx.cover(rel, 'empaquetado (incrustado por completo en el paquete)');
790
+ }
791
+ }
792
+
793
+ mark('css');
794
+ // 3. CSS enlazado
795
+ const cssOut = new Map();
796
+ const cssEntries = new Set(extra.css);
797
+ for (const page of fullPages)
798
+ for (const l of page.model.links)
799
+ if (l.type === 'stylesheet') {
800
+ const res = ctx.resolve(l.href.value, page.baseDir);
801
+ if (res.status === 'ok') cssEntries.add(res.rel);
802
+ else if (res.status !== 'skip') ctx.reportBroken(page.rel, l.href.value, res);
803
+ }
804
+ const cssList = [...cssEntries];
805
+ const cssBuilt = await mapLimit(cssList, 16, (rel) => {
806
+ const hashed = cfg.hash && !extra.css.includes(rel);
807
+ return buildCss(ctx, { entry: rel, outRel: hashed ? `${ctx.chunkDir}/${path.posix.basename(rel)}` : rel });
808
+ });
809
+ for (const [ci, rel] of cssList.entries()) {
810
+ const hashed = cfg.hash && !extra.css.includes(rel);
811
+ const r = cssBuilt[ci];
812
+ if (r.error) {
813
+ ctx.warn(`no se pudo procesar el CSS (${r.error}); se copia tal cual`, rel);
814
+ cssOut.set(rel, ctx.emitCopy(rel, rel, 'copy-fallback'));
815
+ ctx.cover(rel, 'copiado sin cambios (error al procesar)', rel);
816
+ continue;
817
+ }
818
+ const outRel = hashed ? ctx.hashedPath(rel, r.code, '.css') : rel;
819
+ ctx.emitCode(outRel, r.code, r.map, { type: 'css', action: 'bundle', sources: r.inputs, before: sumSizes(ctx, r.inputs) });
820
+ cssOut.set(rel, outRel);
821
+ for (const i of r.inputs) ctx.cover(i, i === rel ? 'procesado' : 'empaquetado', outRel);
822
+ }
823
+
824
+ mark('white-space');
825
+ // 4. Detección de white-space: pre (para no colapsar espacios del HTML)
826
+ let collapse = cfg.html.collapseWhitespace;
827
+ if (collapse === 'auto') {
828
+ const cssText = [...ctx.files.keys()].filter((r) => CSS_EXT.has(extOf(r))).map((r) => ctx.readText(r, { kind: 'css' }).text);
829
+ for (const p of pages.values()) htmlBlob.push(...p.model.styles.map((s) => s.code), ...p.model.styleAttrs.map((s) => s.value));
830
+ const pre = detectPreWhitespace(cssText.join('\n') + htmlBlob.join('\n'));
831
+ collapse = !pre;
832
+ if (pre)
833
+ ctx.note(
834
+ '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)',
835
+ );
836
+ }
837
+
838
+ mark('scripts clásicos');
839
+ // 5. Scripts clásicos por página
840
+ // Primero se planifican todas las páginas, luego se minifica/ofusca cada grupo distinto en paralelo
841
+ // y por último se registra todo en el orden original.
842
+ const pageRuns = [];
843
+ const uniqueRuns = new Map();
844
+ for (const page of fullPages) {
845
+ for (const run of classicRuns(ctx, page)) {
846
+ const key = `${run.items.map((c) => c.rel).join('|')}\0${run.items[0].key}`;
847
+ pageRuns.push({ page, run, key });
848
+ if (!uniqueRuns.has(key)) uniqueRuns.set(key, run);
849
+ }
850
+ }
851
+ const runKeys = [...uniqueRuns.keys()];
852
+ const runResults = await mapLimit(runKeys, 64, (key) => computeClassicRun(ctx, uniqueRuns.get(key)));
853
+ const computedRuns = new Map(runKeys.map((k, i) => [k, runResults[i]]));
854
+ const classicCache = new Map();
855
+ {
856
+ for (const { page, run, key } of pageRuns) {
857
+ let out = classicCache.get(key);
858
+ if (!out) {
859
+ out = commitClassicRun(ctx, run, page, computedRuns.get(key));
860
+ classicCache.set(key, out);
861
+ }
862
+ if (out.merged) {
863
+ // El primer <script> apunta al archivo unido; los demás se quitan.
864
+ const first = run.items[0];
865
+ page.edits.push({ start: first.s.src.start, end: first.s.src.end, text: attrText('src', urlFor(ctx, page, first.s.src.value, out.outRel)) });
866
+ for (const c of run.items.slice(1)) page.edits.push({ start: c.s.start, end: c.s.end, text: '' });
867
+ } else {
868
+ run.items.forEach((c, i) => {
869
+ const o = out.outs[i];
870
+ if (o.outRel !== c.rel)
871
+ page.edits.push({ start: c.s.src.start, end: c.s.src.end, text: attrText('src', urlFor(ctx, page, c.s.src.value, o.outRel)) });
872
+ if (o.changed) setIntegrity(ctx, page, c.s, 'src', ctx.outputBytes(o.outRel));
873
+ });
874
+ }
875
+ for (const c of run.items) if (c.info.ok) ctx.noteJsInfo(c.rel, c.info, path.posix.dirname(c.rel) === '.' ? '' : path.posix.dirname(c.rel));
876
+ }
877
+ }
878
+
879
+ mark('edición html');
880
+ // 6. Edición de cada página: módulos, CSS, inline, preload
881
+ for (const page of fullPages) {
882
+ for (const m of moduleRefs.filter((x) => x.page === page)) {
883
+ const outRel = entryOut.get(`${m.group.key}\0${m.rel}`);
884
+ if (!outRel) continue;
885
+ if (outRel !== m.rel)
886
+ page.edits.push({ start: m.s.src.start, end: m.s.src.end, text: attrText('src', urlFor(ctx, page, m.s.src.value, outRel)) });
887
+ if (ctx.outputs.has(outRel)) setIntegrity(ctx, page, m.s, 'src', ctx.outputBytes(outRel));
888
+ }
889
+ for (const l of page.model.links) {
890
+ if (l.type !== 'stylesheet') continue;
891
+ const res = ctx.resolve(l.href.value, page.baseDir);
892
+ if (res.status !== 'ok' || !cssOut.has(res.rel)) continue;
893
+ const outRel = cssOut.get(res.rel);
894
+ if (outRel !== res.rel)
895
+ page.edits.push({ start: l.href.start, end: l.href.end, text: attrText('href', urlFor(ctx, page, l.href.value, outRel)) });
896
+ setIntegrity(ctx, page, l, 'href', ctx.outputBytes(outRel));
897
+ }
898
+ for (const [id, v] of [...groups.values()].flatMap((g) => [...g.virtual.entries()])) {
899
+ if (v.page !== page || !inlineModuleCode.has(id)) continue;
900
+ page.edits.push({ start: v.s.inline.start, end: v.s.inline.end, text: escapeInlineScript(inlineModuleCode.get(id)) });
901
+ }
902
+ }
903
+ for (const page of pages.values()) await processInline(ctx, page, collapse);
904
+
905
+ mark('workers y sueltos');
906
+ // 7. Workers, archivos sueltos y rutas mencionadas (hasta que no aparezca nada nuevo)
907
+ await drainQueues(ctx, cssOut);
908
+
909
+ mark('no referenciados');
910
+ // 8. Archivos no referenciados
911
+ const unref = [];
912
+ for (const [rel, f] of ctx.files) {
913
+ if (ctx.coverage.has(rel) || ctx.hasOwnOutput(rel) || HTML_EXT.has(f.ext)) continue;
914
+ if (JS_EXT.has(f.ext) || CSS_EXT.has(f.ext)) unref.push(rel);
915
+ }
916
+ for (const rel of unref) {
917
+ if (ctx.coverage.has(rel)) continue;
918
+ const f = ctx.files.get(rel);
919
+ if (cfg.unreferenced === 'process' || f.forced) ctx.requireStandalone(rel, 'no referenciado (se procesa suelto)');
920
+ else if (cfg.unreferenced === 'copy') {
921
+ ctx.emitCopy(rel, rel);
922
+ ctx.cover(rel, 'no referenciado: copiado', rel);
923
+ } else ctx.cover(rel, 'no referenciado: omitido (unreferenced: skip)');
924
+ }
925
+ await drainQueues(ctx, cssOut);
926
+
927
+ mark('html final');
928
+ // 9. Preloads y HTML final
929
+ for (const { page, l } of preloadLinks) {
930
+ const res = ctx.resolve(l.href.value, page.baseDir);
931
+ if (res.status !== 'ok') continue;
932
+ const target =
933
+ cssOut.get(res.rel) ??
934
+ [...entryOut.entries()].find(([k]) => k.endsWith(`\0${res.rel}`))?.[1] ??
935
+ (ctx.hasOwnOutput(res.rel) || ctx.assets.has(res.rel) ? res.rel : null);
936
+ if (target == null) {
937
+ page.edits.push({ start: l.start, end: l.end, text: '' });
938
+ ctx.note(`se quita <link rel="${l.rels.join(' ')}" href="${l.href.value}">: ese archivo ahora va dentro de un paquete`, page.rel);
939
+ } else if (target !== res.rel) {
940
+ page.edits.push({ start: l.href.start, end: l.href.end, text: attrText('href', urlFor(ctx, page, l.href.value, target)) });
941
+ }
942
+ if (target != null && l.attrs.has('integrity') && ctx.outputs.has(target)) setIntegrity(ctx, page, l, 'href', ctx.outputBytes(target));
943
+ }
944
+ for (const page of pages.values()) {
945
+ const referenced = page.full || ctx.pageRefs.has(page.rel) || ctx.mentions.has(page.rel) || ctx.files.get(page.rel).forced;
946
+ if (!referenced && cfg.unreferenced === 'skip') {
947
+ ctx.cover(page.rel, 'no referenciado: omitido (unreferenced: skip)');
948
+ continue;
949
+ }
950
+ if (!referenced && cfg.unreferenced === 'copy') {
951
+ ctx.emitCopy(page.rel, page.rel);
952
+ ctx.cover(page.rel, 'no referenciado: copiado', page.rel);
953
+ continue;
954
+ }
955
+ await finalizePage(ctx, page, collapse);
956
+ }
957
+
958
+ mark('recursos');
959
+ // 10. Recursos: todo lo demás se copia (sin perder nada)
960
+ for (const [rel, f] of ctx.files) {
961
+ if (ctx.hasOwnOutput(rel)) {
962
+ if (!ctx.coverage.has(rel)) ctx.cover(rel, 'procesado', rel);
963
+ continue;
964
+ }
965
+ const cov = ctx.coverage.get(rel);
966
+ // Empaquetar solo absorbe JS y CSS; un JSON importado se publica también por si se pide con fetch().
967
+ 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;
968
+ const referenced = ctx.assets.has(rel) || ctx.mentions.has(rel);
969
+ if (cfg.unreferenced === 'skip' && !referenced && !f.forced) {
970
+ ctx.cover(rel, 'no referenciado: omitido (unreferenced: skip)');
971
+ continue;
972
+ }
973
+ if ((f.ext === '.json' || f.ext === '.webmanifest') && cfg.json.minify) {
974
+ const raw = decodeUtf8(fs.readFileSync(f.abs));
975
+ const min = raw == null ? null : minifyJsonText(stripBom(raw).text);
976
+ if (min != null && Buffer.byteLength(min) < f.size) {
977
+ ctx.emit(rel, min, { type: 'json', action: 'minify', sources: [rel], before: f.size });
978
+ ctx.cover(rel, 'JSON minificado', rel);
979
+ continue;
980
+ }
981
+ }
982
+ ctx.emitCopy(rel, rel);
983
+ ctx.cover(rel, 'copiado', rel);
984
+ }
985
+
986
+ mark('avisos');
987
+ // 11. Avisos de rutas mencionadas en el código que ya no existirán
988
+ for (const [rel, from] of ctx.mentions) {
989
+ if (!ctx.outputs.has(rel) && ctx.coverage.get(rel)?.every((s) => s.startsWith('empaquetado'))) {
990
+ ctx.warn(`"${rel}" se menciona como texto en ${from} pero ya no existe por separado (va dentro de un paquete).`);
991
+ }
992
+ }
993
+ if (cfg.hash) {
994
+ for (const [rel, from] of ctx.mentions) {
995
+ if ((cssOut.get(rel) ?? rel) !== rel)
996
+ ctx.note(`"${rel}" se menciona como texto en ${from}: además de la versión con hash se publica en su ruta original.`);
997
+ }
998
+ }
999
+
1000
+ mark('precompresión');
1001
+ // 12. Precompresión .gz / .br para servidores que sirven archivos comprimidos (nginx gzip_static, etc.)
1002
+ if (cfg.compress && mode === 'build') await precompress(ctx, cfg.compress === true ? ['gzip', 'brotli'] : [cfg.compress]);
1003
+
1004
+ mark('informe de tamaños');
1005
+ const report = await buildReport(ctx.outputs, ctx.heavy);
1006
+ const result = {
1007
+ version: VERSION,
1008
+ root: cfg.root,
1009
+ outDir: cfg.outDir,
1010
+ mode,
1011
+ pages: { full: fullPages.map((p) => p.rel), conservative: partialPages.map((p) => p.rel) },
1012
+ outputs: ctx.outputs,
1013
+ coverage: ctx.coverage,
1014
+ ignored: ctx.ignored,
1015
+ broken: ctx.broken,
1016
+ warnings: ctx.warnings,
1017
+ notes: ctx.notes,
1018
+ workers: [...ctx.workers.entries()].map(([rel, w]) => ({ rel, ...w })),
1019
+ standalone: [...ctx.standalone.entries()].map(([rel, s]) => ({ rel, reason: s.reason })),
1020
+ dependencies: [...ctx.files.keys()]
1021
+ .filter((rel) => JS_EXT.has(extOf(rel)) && ctx.dependencyReason(rel))
1022
+ .map((rel) => ({ rel, reason: ctx.dependencyReason(rel) })),
1023
+ vendor: ctx.vendorPackages,
1024
+ obfuscated: !!ctx.obfuscatorOptions,
1025
+ report,
1026
+ perf: Object.assign(ctx.heavy.stats, { phases }),
1027
+ verify: null,
1028
+ durationMs: 0,
1029
+ };
1030
+ if (mode === 'analyze') {
1031
+ mark('fin');
1032
+ result.durationMs = Date.now() - t0;
1033
+ return result;
1034
+ }
1035
+
1036
+ mark('escritura y verificación');
1037
+ // 12. Escritura, verificación y publicación
1038
+ let staging;
1039
+ try {
1040
+ staging = await writeStaging(cfg, ctx.outputs);
1041
+ if (cfg.verify) {
1042
+ result.verify = await verifyOutput(staging, ctx.outputs, ctx);
1043
+ if (result.verify.errors.length) {
1044
+ if (!opts.keepFailed) await discardStaging(staging);
1045
+ throw new BuildError(`La verificación encontró ${result.verify.errors.length} problema(s); no se tocó ${cfg.outDir}.`, result.verify.errors);
1046
+ }
1047
+ }
1048
+ await writeManifest(staging, {
1049
+ tool: 'vanilla-builder',
1050
+ version: VERSION,
1051
+ builtAt: new Date().toISOString(),
1052
+ files: [...ctx.outputs.keys()].sort(),
1053
+ });
1054
+ await swapIntoPlace(cfg, staging);
1055
+ mark('fin');
1056
+ } catch (err) {
1057
+ if (staging && !opts.keepFailed) await discardStaging(staging);
1058
+ throw err;
1059
+ }
1060
+ result.durationMs = Date.now() - t0;
1061
+ return result;
1062
+ }
1063
+
1064
+ const COMPRESS_TYPES = new Set(['html', 'css', 'js', 'json', 'svg', 'text', 'map']);
1065
+
1066
+ async function precompress(ctx, kinds) {
1067
+ const items = [...ctx.outputs].filter(([, o]) => COMPRESS_TYPES.has(o.type));
1068
+ // En paralelo (pool de hilos de libuv); se registra en orden. El informe reutiliza estos mismos resultados.
1069
+ const done = await mapLimit(items, 64, async ([, o]) => {
1070
+ const buf = o.copyFrom ? fs.readFileSync(o.copyFrom) : o.data;
1071
+ if (buf.length < 1024) return null; // no compensa
1072
+ const [gz, br] = await Promise.all([kinds.includes('gzip') ? gzipOf(buf) : null, kinds.includes('brotli') ? brotliOf(buf) : null]);
1073
+ return { buf, gz, br };
1074
+ });
1075
+ items.forEach(([rel, o], i) => {
1076
+ const d = done[i];
1077
+ if (!d) return;
1078
+ if (d.gz && d.gz.length < d.buf.length) ctx.emit(`${rel}.gz`, d.gz, { type: 'compressed', action: 'gzip', sources: o.sources });
1079
+ if (d.br && d.br.length < d.buf.length) ctx.emit(`${rel}.br`, d.br, { type: 'compressed', action: 'brotli', sources: o.sources });
1080
+ });
1081
+ }
1082
+
1083
+ function sumSizes(ctx, rels) {
1084
+ return rels.reduce((a, r) => a + (ctx.files.get(r)?.size ?? 0), 0);
1085
+ }
1086
+
1087
+ function markModuleImport(ctx, spec, baseDir, from, importMap) {
1088
+ const c = classifyUrl(spec);
1089
+ if (c.type !== 'root' && c.type !== 'relative') {
1090
+ if (importMap?.imports && c.type !== 'external') {
1091
+ const target = hasOwn(importMap.imports, spec)
1092
+ ? importMap.imports[spec]
1093
+ : Object.keys(importMap.imports)
1094
+ .filter((k) => k.endsWith('/') && spec.startsWith(k))
1095
+ .map((k) => importMap.imports[k] + spec.slice(k.length))[0];
1096
+ if (target) {
1097
+ const r = ctx.resolve(target, importMap.baseDir);
1098
+ if (r.status === 'ok') ctx.requireStandalone(r.rel, `importado desde ${from}`, { module: true });
1099
+ }
1100
+ }
1101
+ return;
1102
+ }
1103
+ const r = ctx.resolve(spec, baseDir);
1104
+ if (r.status === 'ok') ctx.requireStandalone(r.rel, `importado desde ${from}`, { module: true });
1105
+ else ctx.reportBroken(from, spec, r);
1106
+ }
1107
+
1108
+ /** Parte costosa y sin efectos de un grupo de scripts clásicos: unir, minificar y ofuscar. */
1109
+ async function computeClassicRun(ctx, run) {
1110
+ const items = run.items;
1111
+ const first = items[0];
1112
+ if (items.length === 1 && !first.info.ok) return { copy: true };
1113
+ let mergeError = null;
1114
+ if (items.length > 1) {
1115
+ // Separadores con ; y salto de línea: evitan que un archivo sin ; final se "pegue" al siguiente.
1116
+ const code = items.map((c) => ctx.readText(c.rel, { kind: 'js', charset: c.s.attrs.get('charset') }).text.replace(/\s*$/, '\n')).join(';\n');
1117
+ const info = analyzeJs(code, { module: false });
1118
+ if (info.ok) return { merged: await processClassic(ctx, { code, id: items.map((c) => c.rel).join('+'), info, obfuscate: first.obf }) };
1119
+ mergeError = info.error;
1120
+ }
1121
+ const separate = await Promise.all(
1122
+ items.map((c) => {
1123
+ const { text } = ctx.readText(c.rel, { kind: 'js', charset: c.s.attrs.get('charset') });
1124
+ return processClassic(ctx, { code: text, id: c.rel, info: c.info, obfuscate: c.obf, sourcemap: !!ctx.cfg.sourcemap });
1125
+ }),
1126
+ );
1127
+ return { separate, mergeError };
1128
+ }
1129
+
1130
+ /** Registra el resultado de computeClassicRun (salidas, cobertura y notas) en orden. */
1131
+ function commitClassicRun(ctx, run, page, comp) {
1132
+ const items = run.items;
1133
+ const first = items[0];
1134
+ if (comp.copy) {
1135
+ ctx.warn(`no se pudo analizar (${first.info.error}); se copia sin cambios`, first.rel);
1136
+ const outRel = ctx.emitCopy(first.rel, first.rel, 'copy-fallback');
1137
+ ctx.cover(first.rel, 'copiado sin cambios (no se pudo analizar)', outRel);
1138
+ return { merged: false, outs: [{ outRel, changed: false }] };
1139
+ }
1140
+ {
1141
+ if (comp.merged) {
1142
+ const r = comp.merged;
1143
+ for (const n of r.notes) ctx.note(n, `scripts unidos (${items.map((c) => c.rel).join(', ')})`);
1144
+ const outRel = ctx.emitCode(`${ctx.chunkDir}/scripts-${shortHash(r.code)}.js`, r.code, null, {
1145
+ type: 'js',
1146
+ action: 'merge',
1147
+ sources: items.map((c) => c.rel),
1148
+ before: sumSizes(
1149
+ ctx,
1150
+ items.map((c) => c.rel),
1151
+ ),
1152
+ });
1153
+ for (const c of items) ctx.cover(c.rel, 'unido', outRel);
1154
+ return { merged: true, outRel };
1155
+ }
1156
+ if (comp.mergeError) ctx.warn(`no se pudieron unir (${comp.mergeError}); se procesan por separado`, page.rel);
1157
+ }
1158
+ // Un solo archivo, o una unión fallida: cada uno se procesa en su sitio.
1159
+ const outs = [];
1160
+ for (const [i, c] of items.entries()) {
1161
+ const r = comp.separate[i];
1162
+ for (const n of r.notes) ctx.note(n, c.rel);
1163
+ if (c.reasons.length && c.reasons[0] !== 'unión desactivada') ctx.note(`no se une con otros scripts: ${c.reasons.join(', ')}`, c.rel);
1164
+ const outRel = ctx.cfg.hash ? ctx.hashedPath(c.rel, r.code, '.js') : jsOutPath(c.rel);
1165
+ ctx.emitCode(outRel, r.code, r.map, { type: 'js', action: 'minify', sources: [c.rel], before: ctx.files.get(c.rel).size });
1166
+ ctx.cover(c.rel, 'procesado', outRel);
1167
+ outs.push({ outRel, changed: true });
1168
+ }
1169
+ return { merged: false, outs };
1170
+ }
1171
+
1172
+ /** Scripts y estilos inline, atributos style y JSON inline de una página. */
1173
+ async function processInline(ctx, page) {
1174
+ const cfg = ctx.cfg;
1175
+ for (const s of page.model.scripts) {
1176
+ if (s.src) {
1177
+ if (!page.full) {
1178
+ const res = ctx.resolve(s.src.value, page.baseDir);
1179
+ if (res.status === 'ok') ctx.requireStandalone(res.rel, `script en ${page.rel}`, { module: s.kind === 'module' });
1180
+ else if (res.status !== 'skip') ctx.reportBroken(page.rel, s.src.value, res);
1181
+ }
1182
+ continue;
1183
+ }
1184
+ if (!s.inline || !s.inline.code.trim()) continue;
1185
+ if (s.kind === 'classic') {
1186
+ const info = analyzeJs(s.inline.code, { module: false });
1187
+ if (!info.ok) {
1188
+ ctx.note(`un <script> inline no es JS estándar (¿plantilla?); se deja igual`, page.rel);
1189
+ continue;
1190
+ }
1191
+ ctx.noteJsInfo(page.rel, info, page.baseDir);
1192
+ const r = await processClassic(ctx, { code: s.inline.code, id: `${page.rel}#inline`, info, obfuscate: ctx.shouldObfuscate([page.rel]) });
1193
+ for (const n of r.notes) ctx.note(`script inline: ${n}`, page.rel);
1194
+ if (r.code !== s.inline.code)
1195
+ page.edits.push({ start: s.inline.start, end: s.inline.end, text: escapeInlineScript(r.code.replace(/\n$/, '')) });
1196
+ } else if (s.kind === 'module' && !page.full) {
1197
+ const info = analyzeJs(s.inline.code, { module: true });
1198
+ if (!info.ok) continue;
1199
+ ctx.noteJsInfo(page.rel, info, page.baseDir);
1200
+ for (const imp of info.imports) if (imp.spec) markModuleImport(ctx, imp.spec, page.baseDir, page.rel, null);
1201
+ const r = await processModuleStandalone(ctx, {
1202
+ code: s.inline.code,
1203
+ id: `${page.rel}#module`,
1204
+ info,
1205
+ obfuscate: ctx.shouldObfuscate([page.rel]),
1206
+ });
1207
+ if (r.code !== s.inline.code)
1208
+ page.edits.push({ start: s.inline.start, end: s.inline.end, text: escapeInlineScript(r.code.replace(/\n$/, '')) });
1209
+ } else if ((s.kind === 'json' || s.kind === 'importmap') && cfg.json.minify) {
1210
+ const min = minifyJsonText(s.inline.code.trim());
1211
+ if (min != null && min !== s.inline.code) page.edits.push({ start: s.inline.start, end: s.inline.end, text: escapeInlineScript(min) });
1212
+ }
1213
+ }
1214
+ if (!page.full) {
1215
+ for (const l of page.model.links) {
1216
+ if (l.type !== 'stylesheet') continue;
1217
+ const res = ctx.resolve(l.href.value, page.baseDir);
1218
+ if (res.status === 'ok') ctx.requireStandalone(res.rel, `hoja de estilos en ${page.rel}`);
1219
+ else if (res.status !== 'skip') ctx.reportBroken(page.rel, l.href.value, res);
1220
+ }
1221
+ }
1222
+ if (cfg.css.minify) {
1223
+ for (const st of page.model.styles) {
1224
+ if (!st.code.trim()) continue;
1225
+ const r = await buildCss(ctx, {
1226
+ inline: { code: st.code, baseDir: page.baseDir, label: `${page.rel} (<style>)` },
1227
+ outRel: page.baseDir ? `${page.baseDir}/__page.html` : '__page.html',
1228
+ });
1229
+ if (r.error) {
1230
+ ctx.note(`un <style> no se pudo minificar (${r.error.split('\n')[0]}); se deja igual`, page.rel);
1231
+ continue;
1232
+ }
1233
+ if (r.code !== st.code) page.edits.push({ start: st.start, end: st.end, text: escapeInlineStyle(r.code) });
1234
+ }
1235
+ for (const a of page.model.styleAttrs) {
1236
+ const min = await minifyStyleAttr(a.value, cfg.css.target);
1237
+ if (min !== a.value) page.edits.push({ start: a.start, end: a.end, text: attrText('style', min) });
1238
+ }
1239
+ }
1240
+ }
1241
+
1242
+ async function finalizePage(ctx, page, collapse) {
1243
+ let text;
1244
+ try {
1245
+ text = applyEdits(page.text, page.edits);
1246
+ } catch (err) {
1247
+ throw new BuildError(`Error interno al editar ${page.rel}: ${err.message}`);
1248
+ }
1249
+ const cfg = ctx.cfg;
1250
+ const notes = [];
1251
+ if (cfg.html.minify && page.encoding !== 'latin1') {
1252
+ const r = await minifyHtml(text, { removeComments: cfg.html.removeComments, collapseWhitespace: collapse });
1253
+ if (r.note) notes.push(r.note);
1254
+ text = r.code;
1255
+ }
1256
+ for (const n of notes) ctx.note(n, page.rel);
1257
+ const data = page.encoding === 'latin1' ? Buffer.from(text, 'latin1') : Buffer.from((page.bom ? BOM : '') + text, 'utf8');
1258
+ ctx.emit(page.rel, data, {
1259
+ type: 'html',
1260
+ action: page.full ? 'html' : 'html-conservative',
1261
+ sources: [page.rel],
1262
+ before: ctx.files.get(page.rel).size,
1263
+ encoding: page.encoding === 'latin1' ? 'latin1' : 'utf8',
1264
+ baseDir: page.baseDir,
1265
+ baseExternal: page.baseExternal,
1266
+ });
1267
+ ctx.cover(page.rel, page.full ? 'procesado' : 'procesado (modo conservador)', page.rel);
1268
+ }
1269
+
1270
+ /** Procesa workers y archivos que deben existir sueltos en su ruta original, hasta agotar la cola. */
1271
+ async function drainQueues(ctx, cssOut) {
1272
+ for (let round = 0; round < 50; round++) {
1273
+ let progressed = false;
1274
+ // Rutas mencionadas como texto (precache de service workers, fetch...) que fueron empaquetadas.
1275
+ for (const [rel] of ctx.mentions) {
1276
+ const ext = extOf(rel);
1277
+ if ((JS_EXT.has(ext) || CSS_EXT.has(ext)) && !ctx.hasOwnOutput(rel) && !ctx.standalone.has(rel))
1278
+ ctx.requireStandalone(rel, `mencionado como texto en ${ctx.mentions.get(rel)}`);
1279
+ else if (!JS_EXT.has(ext) && !CSS_EXT.has(ext) && !HTML_EXT.has(ext)) ctx.registerAsset(rel, ctx.mentions.get(rel));
1280
+ }
1281
+ for (const [rel, w] of ctx.workers) {
1282
+ if (ctx.done.has(`w:${rel}`)) continue;
1283
+ ctx.done.add(`w:${rel}`);
1284
+ progressed = true;
1285
+ if (ctx.hasOwnOutput(rel)) continue;
1286
+ if (w.module) {
1287
+ const r = await buildModuleWorker(ctx, rel);
1288
+ if (r.error) {
1289
+ ctx.warn(`no se pudo empaquetar el worker (${r.error.split('\n')[0]}); se procesa suelto`, rel);
1290
+ ctx.requireStandalone(rel, 'worker (respaldo)', { module: true });
1291
+ continue;
1292
+ }
1293
+ let code = r.code;
1294
+ if (ctx.shouldObfuscate([rel])) {
1295
+ const o = await obfuscateModule(ctx, code, rel);
1296
+ if (o.note) ctx.warn(o.note, rel);
1297
+ code = o.code;
1298
+ } else code = await terserPass(ctx, code, { isModule: true, level: 2020 });
1299
+ const sources = [...r.consumed];
1300
+ ctx.emitCode(jsOutPath(rel), code, code === r.code ? r.map : null, {
1301
+ type: 'js',
1302
+ module: true,
1303
+ action: 'worker',
1304
+ sources,
1305
+ before: sumSizes(ctx, sources),
1306
+ });
1307
+ for (const s of sources) ctx.cover(s, s === rel ? 'procesado (worker)' : 'empaquetado', jsOutPath(rel));
1308
+ } else ctx.requireStandalone(rel, `worker clásico (${w.kind})`, { classic: true });
1309
+ }
1310
+ // Análisis en orden, trabajo pesado en paralelo, registro otra vez en orden.
1311
+ const jobs = [];
1312
+ for (const [rel, s] of ctx.standalone) {
1313
+ if (ctx.done.has(`s:${rel}`)) continue;
1314
+ ctx.done.add(`s:${rel}`);
1315
+ progressed = true;
1316
+ if (ctx.hasOwnOutput(rel) || !ctx.files.has(rel)) continue;
1317
+ const job = planStandalone(ctx, rel, s, cssOut);
1318
+ if (job) jobs.push(job);
1319
+ }
1320
+ const results = await mapLimit(jobs, 64, (j) => j.run());
1321
+ jobs.forEach((j, i) => j.commit(results[i]));
1322
+ if (!progressed) return;
1323
+ }
1324
+ throw new BuildError('Demasiadas rondas al resolver dependencias (¿ciclo inesperado?).');
1325
+ }
1326
+
1327
+ /**
1328
+ * Prepara un archivo suelto: el análisis (que descubre más dependencias) se hace ya, en orden;
1329
+ * devuelve {run, commit}: run() es la parte costosa y sin efectos, commit() registra el resultado.
1330
+ */
1331
+ function planStandalone(ctx, rel, s, cssOut) {
1332
+ const ext = extOf(rel);
1333
+ const dir = path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel);
1334
+ if (CSS_EXT.has(ext)) {
1335
+ // buildCss registra los recursos de url() mientras compila: eso solo añade trabajo a la siguiente ronda.
1336
+ return {
1337
+ run: () => buildCss(ctx, { entry: rel, outRel: rel }),
1338
+ commit: (r) => {
1339
+ if (r.error) {
1340
+ ctx.warn(`no se pudo procesar el CSS (${r.error.split('\n')[0]}); se copia tal cual`, rel);
1341
+ ctx.emitCopy(rel, rel, 'copy-fallback');
1342
+ ctx.cover(rel, 'copiado sin cambios (error al procesar)', rel);
1343
+ return;
1344
+ }
1345
+ ctx.emitCode(rel, r.code, r.map, { type: 'css', action: 'standalone', sources: [rel], before: ctx.files.get(rel).size });
1346
+ for (const i of r.inputs) ctx.cover(i, i === rel ? 'procesado (suelto)' : 'empaquetado', rel);
1347
+ if (!cssOut.has(rel)) cssOut.set(rel, rel);
1348
+ },
1349
+ };
1350
+ }
1351
+ if (!JS_EXT.has(ext)) {
1352
+ ctx.registerAsset(rel, s.reason);
1353
+ return null;
1354
+ }
1355
+ const auto = ctx.jsInfo(rel, 'auto');
1356
+ const isModule = !s.classic && (s.module || (auto.ok && (auto.hasModuleSyntax || auto.hasImportMeta)) || (!auto.ok && auto.isModule));
1357
+ const info = isModule ? ctx.jsInfo(rel, true) : ctx.jsInfo(rel, false);
1358
+ if (!info.ok) {
1359
+ ctx.warn(`no se pudo analizar (${info.error}); se copia sin cambios`, rel);
1360
+ ctx.emitCopy(rel, rel, 'copy-fallback');
1361
+ ctx.cover(rel, 'copiado sin cambios (no se pudo analizar)', rel);
1362
+ return null;
1363
+ }
1364
+ ctx.noteJsInfo(rel, info, dir);
1365
+ const { text } = ctx.readText(rel, { kind: 'js' });
1366
+ const obfuscate = ctx.shouldObfuscate([rel]);
1367
+ if (isModule) {
1368
+ for (const imp of info.imports) if (imp.spec) markModuleImport(ctx, imp.spec, dir, rel, null);
1369
+ for (const u of info.urls) {
1370
+ const res = ctx.resolve(u.spec, dir);
1371
+ if (res.status === 'ok') ctx.urlTarget(res.rel, rel);
1372
+ else ctx.reportBroken(rel, u.spec, res);
1373
+ }
1374
+ }
1375
+ const job = { code: text, id: rel, info, obfuscate, sourcemap: !!ctx.cfg.sourcemap };
1376
+ return {
1377
+ run: () => (isModule ? processModuleStandalone(ctx, job) : processClassic(ctx, job)),
1378
+ commit: (r) => {
1379
+ for (const n of r.notes) ctx.note(n, rel);
1380
+ const out = jsOutPath(rel);
1381
+ ctx.emitCode(out, r.code, r.map, { type: 'js', module: isModule, action: 'standalone', sources: [rel], before: ctx.files.get(rel).size });
1382
+ ctx.cover(rel, `procesado suelto (${s.reason})`, out);
1383
+ },
1384
+ };
1385
+ }