vanilla-builder 0.0.0-stage → 0.1.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,1202 @@
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 zlib from 'node:zlib';
5
+ import { scanProject, matchFiles, globMatcher } from './files.js';
6
+ import { resolveRef, classifyUrl, parseSrcset } from './urls.js';
7
+ import { analyzeJs } from './jsinfo.js';
8
+ import { parseHtml, adjacentSiblings, applyEdits, attrText, escapeInlineScript, escapeInlineStyle, minifyHtml, detectPreWhitespace } from './html.js';
9
+ import { buildCss, minifyStyleAttr } from './css.js';
10
+ import {
11
+ processClassic,
12
+ processModuleStandalone,
13
+ buildModuleGroup,
14
+ buildModuleWorker,
15
+ obfuscateModule,
16
+ ensureTerser,
17
+ terserPass,
18
+ levelOf,
19
+ } from './js.js';
20
+ import { resolveObfuscatorOptions, ensureObfuscator } from './obfuscate.js';
21
+ import { detectDependency } from './deps.js';
22
+ import { assertSafeOutDir, writeStaging, swapIntoPlace, discardStaging, writeManifest, MANIFEST } from './output.js';
23
+ import { verifyOutput } from './verify.js';
24
+ import { buildReport } from './report.js';
25
+ import {
26
+ BuildError,
27
+ decodeUtf8,
28
+ stripBom,
29
+ BOM,
30
+ extOf,
31
+ JS_EXT,
32
+ CSS_EXT,
33
+ HTML_EXT,
34
+ shortHash,
35
+ sri,
36
+ encodePath,
37
+ minifyJsonText,
38
+ hasOwn,
39
+ } from './util.js';
40
+
41
+ export const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
42
+
43
+ const MERGE_ATTRS = new Set(['src', 'type', 'defer', 'charset', 'language']);
44
+ const typeOfExt = (ext) =>
45
+ HTML_EXT.has(ext)
46
+ ? 'html'
47
+ : JS_EXT.has(ext)
48
+ ? 'js'
49
+ : CSS_EXT.has(ext)
50
+ ? 'css'
51
+ : ext === '.json' || ext === '.webmanifest'
52
+ ? 'json'
53
+ : ext === '.svg'
54
+ ? 'svg'
55
+ : ['.txt', '.xml', '.md', '.csv'].includes(ext)
56
+ ? 'text'
57
+ : 'asset';
58
+
59
+ class Context {
60
+ constructor(cfg, opts) {
61
+ this.cfg = cfg;
62
+ this.opts = opts;
63
+ this.root = cfg.root;
64
+ const { files, ignored } = scanProject(cfg);
65
+ this.files = files;
66
+ this.ignored = ignored;
67
+ this.ignoredSet = new Set(ignored.map((i) => i.rel));
68
+ this.lowerMap = new Map([...files.keys()].map((k) => [k.toLowerCase(), k]));
69
+ this.base = cfg.base;
70
+ this.warnings = [];
71
+ this.notes = [];
72
+ this.broken = [];
73
+ this.brokenKeys = new Set();
74
+ this.brokenValues = new Set();
75
+ this.assets = new Map();
76
+ this.standalone = new Map();
77
+ this.workers = new Map();
78
+ this.mentions = new Map();
79
+ this.coverage = new Map();
80
+ this.outputs = new Map();
81
+ this.outLower = new Map();
82
+ this.textCache = new Map();
83
+ this.infoCache = new Map();
84
+ this.pageDirs = new Set(['']);
85
+ this.pageRefs = new Set();
86
+ this.done = new Set();
87
+ this.obfuscatorOptions = opts.mode === 'analyze' ? null : resolveObfuscatorOptions(cfg.obfuscate);
88
+ this.depIncluded = globMatcher(cfg.dependencies.include);
89
+ this.depExcluded = globMatcher(cfg.dependencies.exclude);
90
+ this.depCache = new Map();
91
+ this.vendorPackages = [];
92
+ this.chunkDir = cfg.js.chunkDir.replace(/\/+$/, '');
93
+ }
94
+
95
+ abs(rel) {
96
+ return path.join(this.root, rel);
97
+ }
98
+
99
+ resolve(spec, baseDir, opts) {
100
+ return resolveRef(spec, baseDir, this, opts);
101
+ }
102
+
103
+ /** Intenta resolver una ruta escrita como texto contra varias carpetas posibles. */
104
+ resolveLoose(spec, dirs) {
105
+ const c = classifyUrl(spec);
106
+ if (c.type !== 'root' && c.type !== 'relative') return null;
107
+ for (const d of dirs) {
108
+ const r = this.resolve(spec, d);
109
+ if (r.status === 'ok') return r.rel;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ warn(msg, file) {
115
+ const text = file ? `${file}: ${msg}` : msg;
116
+ if (!this.warnings.includes(text)) this.warnings.push(text);
117
+ }
118
+
119
+ note(msg, file) {
120
+ const text = file ? `${file}: ${msg}` : msg;
121
+ if (!this.notes.includes(text)) this.notes.push(text);
122
+ }
123
+
124
+ readText(rel, { kind = 'text', charset } = {}) {
125
+ const key = `${rel}\0${kind}`;
126
+ if (this.textCache.has(key)) return this.textCache.get(key);
127
+ const buf = fs.readFileSync(this.abs(rel));
128
+ let text = decodeUtf8(buf);
129
+ let encoding = 'utf8';
130
+ if (text == null) {
131
+ if (kind === 'html') {
132
+ // latin1 conserva cada byte tal cual: lo que no se edita sale idéntico.
133
+ text = buf.toString('latin1');
134
+ encoding = 'latin1';
135
+ this.warn('no es UTF-8; se conserva su codificación original y no se minifica', rel);
136
+ } else {
137
+ let label = (charset || 'windows-1252').toLowerCase();
138
+ try {
139
+ text = new TextDecoder(label).decode(buf);
140
+ } catch {
141
+ label = 'windows-1252';
142
+ text = new TextDecoder(label).decode(buf);
143
+ }
144
+ 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);
146
+ }
147
+ }
148
+ const stripped = stripBom(text);
149
+ const res = { text: stripped.text, bom: stripped.bom, encoding };
150
+ this.textCache.set(key, res);
151
+ return res;
152
+ }
153
+
154
+ jsInfo(rel, module = 'auto') {
155
+ const key = `${rel}\0${module}`;
156
+ if (!this.infoCache.has(key)) this.infoCache.set(key, analyzeJs(this.readText(rel, { kind: 'js' }).text, { module }));
157
+ return this.infoCache.get(key);
158
+ }
159
+
160
+ reportBroken(from, url, r) {
161
+ const key = `${from}\0${url}`;
162
+ this.brokenValues.add(url);
163
+ if (this.brokenKeys.has(key)) return;
164
+ this.brokenKeys.add(key);
165
+ 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);
170
+ }
171
+
172
+ registerAsset(rel, from) {
173
+ if (!this.assets.has(rel)) this.assets.set(rel, new Set());
174
+ if (from) this.assets.get(rel).add(from);
175
+ const ext = extOf(rel);
176
+ if (JS_EXT.has(ext) || CSS_EXT.has(ext)) this.requireStandalone(rel, `referenciado desde ${from}`);
177
+ }
178
+
179
+ assetOut(rel) {
180
+ return rel;
181
+ }
182
+
183
+ /** Destino de new URL('...', import.meta.url): siempre la ruta original del archivo. */
184
+ urlTarget(rel, from) {
185
+ const ext = extOf(rel);
186
+ if (JS_EXT.has(ext) || CSS_EXT.has(ext)) {
187
+ if (!this.workers.has(rel)) this.requireStandalone(rel, `new URL() en ${from}`);
188
+ } else this.registerAsset(rel, from);
189
+ return rel;
190
+ }
191
+
192
+ requireStandalone(rel, reason, extra = {}) {
193
+ if (!this.standalone.has(rel)) this.standalone.set(rel, { reason, ...extra });
194
+ else if (extra.module) this.standalone.get(rel).module = true;
195
+ }
196
+
197
+ registerWorker(rel, info) {
198
+ const prev = this.workers.get(rel);
199
+ 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);
201
+ prev.module = prev.module || info.module;
202
+ return;
203
+ }
204
+ this.workers.set(rel, info);
205
+ }
206
+
207
+ /** Registra workers, service workers, importScripts y rutas mencionadas como texto. */
208
+ noteJsInfo(id, info, baseDir) {
209
+ for (const w of info.workers) {
210
+ 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);
212
+ continue;
213
+ }
214
+ const c = classifyUrl(w.spec);
215
+ if (c.type !== 'root' && c.type !== 'relative') continue;
216
+ const dirs = w.viaUrl || w.kind === 'importScripts' ? [baseDir] : [baseDir, ...this.pageDirs];
217
+ const rel = this.resolveLoose(w.spec, dirs);
218
+ if (!rel) {
219
+ this.reportBroken(id, w.spec, this.resolve(w.spec, baseDir));
220
+ continue;
221
+ }
222
+ if (w.kind === 'importScripts') this.requireStandalone(rel, `importScripts en ${id}`, { classic: true });
223
+ else this.registerWorker(rel, { module: w.module, kind: w.kind, from: id });
224
+ }
225
+ for (const s of info.strings) {
226
+ const rel = this.resolveLoose(s, [baseDir, ...this.pageDirs]);
227
+ if (rel && !this.mentions.has(rel)) this.mentions.set(rel, id);
228
+ }
229
+ }
230
+
231
+ cover(rel, status, out) {
232
+ if (!this.files.has(rel)) return;
233
+ if (!this.coverage.has(rel)) this.coverage.set(rel, []);
234
+ this.coverage.get(rel).push(out ? `${status} → ${out}` : status);
235
+ }
236
+
237
+ hasOwnOutput(rel) {
238
+ const o = this.outputs.get(rel);
239
+ return !!o && o.sources?.includes(rel);
240
+ }
241
+
242
+ /** ¿Es una librería de terceros? (a mano en dependencies.include/exclude, o detectada). */
243
+ dependencyReason(rel) {
244
+ if (this.depCache.has(rel)) return this.depCache.get(rel);
245
+ const deps = this.cfg.dependencies;
246
+ let reason = null;
247
+ if (this.depExcluded(rel)) reason = null;
248
+ 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);
251
+ this.depCache.set(rel, reason);
252
+ return reason;
253
+ }
254
+
255
+ shouldObfuscate(rels) {
256
+ if (!this.obfuscatorOptions) return false;
257
+ const list = rels.filter(Boolean);
258
+ if (!list.length) return false;
259
+ if (this.cfg.dependencies.obfuscate) return true;
260
+ return !list.every((r) => this.dependencyReason(r));
261
+ }
262
+
263
+ /** Registra un archivo de salida; nunca sobrescribe en silencio un contenido distinto. */
264
+ emit(outRel, data, meta) {
265
+ 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}`);
267
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, meta.encoding === 'latin1' ? 'latin1' : 'utf8');
268
+ const prev = this.outputs.get(norm);
269
+ if (prev) {
270
+ const same = prev.copyFrom ? fs.readFileSync(prev.copyFrom).equals(buf) : prev.data.equals(buf);
271
+ 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
+ ]);
277
+ prev.sources = [...new Set([...(prev.sources ?? []), ...(meta.sources ?? [])])];
278
+ return norm;
279
+ }
280
+ const lower = norm.toLowerCase();
281
+ 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.`);
283
+ this.outLower.set(lower, norm);
284
+ this.outputs.set(norm, { ...meta, data: buf });
285
+ return norm;
286
+ }
287
+
288
+ emitCopy(outRel, rel, action = 'copy') {
289
+ const f = this.files.get(rel);
290
+ const prev = this.outputs.get(outRel);
291
+ if (prev) {
292
+ if (prev.sources?.includes(rel)) return outRel;
293
+ return this.emit(outRel, fs.readFileSync(f.abs), { type: typeOfExt(f.ext), action, sources: [rel] });
294
+ }
295
+ this.outputs.set(outRel, { copyFrom: f.abs, size: f.size, type: typeOfExt(f.ext), action, sources: [rel] });
296
+ this.outLower.set(outRel.toLowerCase(), outRel);
297
+ return outRel;
298
+ }
299
+
300
+ /** Añade el source map según la configuración y registra el archivo. */
301
+ emitCode(outRel, code, map, meta) {
302
+ const sm = this.cfg.sourcemap;
303
+ let text = code;
304
+ if (map && sm) {
305
+ const isCss = meta.type === 'css';
306
+ if (sm === 'inline') {
307
+ const url = `data:application/json;base64,${Buffer.from(map).toString('base64')}`;
308
+ text += isCss ? `\n/*# sourceMappingURL=${url} */\n` : `\n//# sourceMappingURL=${url}\n`;
309
+ } else {
310
+ this.emit(`${outRel}.map`, map, { type: 'map', action: 'sourcemap', sources: meta.sources });
311
+ if (sm !== 'hidden') {
312
+ const name = path.posix.basename(outRel);
313
+ text += isCss ? `\n/*# sourceMappingURL=${name}.map */\n` : `\n//# sourceMappingURL=${name}.map\n`;
314
+ }
315
+ }
316
+ }
317
+ return this.emit(outRel, text, meta);
318
+ }
319
+
320
+ hashedPath(rel, data, ext) {
321
+ const name = path.posix.basename(rel, path.posix.extname(rel));
322
+ return `${this.chunkDir}/${name}-${shortHash(data)}${ext}`;
323
+ }
324
+
325
+ outputBytes(outRel) {
326
+ const o = this.outputs.get(outRel);
327
+ return o.copyFrom ? fs.readFileSync(o.copyFrom) : o.data;
328
+ }
329
+ }
330
+
331
+ /** URL para escribir en el HTML, respetando si el original era absoluto (/x) o relativo. */
332
+ function urlFor(ctx, page, originalValue, targetRel) {
333
+ const info = classifyUrl(originalValue);
334
+ const suffix = info.suffix ?? '';
335
+ if (info.type === 'root') return `${ctx.base}${encodePath(targetRel)}${suffix}`;
336
+ let rel = path.posix.relative(page.baseDir || '.', targetRel);
337
+ if (!rel) rel = path.posix.basename(targetRel);
338
+ return encodePath(rel) + suffix;
339
+ }
340
+
341
+ /** Lee y analiza todos los HTML del proyecto. */
342
+ function loadPages(ctx) {
343
+ const pages = new Map();
344
+ for (const [rel, f] of ctx.files) {
345
+ if (!HTML_EXT.has(f.ext)) continue;
346
+ const { text, bom, encoding } = ctx.readText(rel, { kind: 'html' });
347
+ let model;
348
+ try {
349
+ model = parseHtml(text);
350
+ } catch (err) {
351
+ ctx.warn(`no se pudo analizar el HTML (${err.message}); se copia tal cual`, rel);
352
+ continue;
353
+ }
354
+ const dir = path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel);
355
+ const page = { rel, text, bom, encoding, model, dir, baseDir: dir, baseExternal: false, full: false, edits: [] };
356
+ if (model.base != null) {
357
+ const c = classifyUrl(model.base);
358
+ if (c.type === 'external') {
359
+ 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);
361
+ } else if (c.type === 'root' || c.type === 'relative') {
362
+ 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
+ page.baseDir = path.posix
364
+ .normalize(p.endsWith('/') ? p : path.posix.dirname(p))
365
+ .replace(/^\.$/, '')
366
+ .replace(/\/$/, '');
367
+ }
368
+ }
369
+ pages.set(rel, page);
370
+ }
371
+ return pages;
372
+ }
373
+
374
+ /** Decide qué páginas se procesan completas (entradas + enlazadas) y cuáles en modo conservador. */
375
+ function selectEntries(ctx, pages) {
376
+ const cfg = ctx.cfg;
377
+ const extraEntries = { modules: [], classic: [], css: [] };
378
+ let queue = [];
379
+ if (cfg.entries === 'auto') {
380
+ queue = [...pages.values()].filter((p) => p.model.isDocument).map((p) => p.rel);
381
+ } else {
382
+ const { matched, missing } = matchFiles(ctx.files, cfg.entries);
383
+ if (missing.length) throw new BuildError('Algunas entradas de "entries" no existen:', missing);
384
+ for (const rel of matched) {
385
+ const ext = extOf(rel);
386
+ if (HTML_EXT.has(ext)) queue.push(rel);
387
+ else if (CSS_EXT.has(ext)) extraEntries.css.push(rel);
388
+ else if (JS_EXT.has(ext)) {
389
+ const info = ctx.jsInfo(rel);
390
+ (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');
392
+ }
393
+ }
394
+ const full = new Set();
395
+ while (queue.length) {
396
+ const rel = queue.shift();
397
+ const page = pages.get(rel);
398
+ if (!page || full.has(rel)) continue;
399
+ if (page.encoding === 'latin1' || page.baseExternal) {
400
+ continue; // se procesa en modo conservador
401
+ }
402
+ full.add(rel);
403
+ page.full = true;
404
+ ctx.pageDirs.add(page.baseDir);
405
+ for (const r of page.model.refs) {
406
+ if (r.kind !== 'page') continue;
407
+ const res = ctx.resolve(r.value, page.baseDir, { page: true });
408
+ if (res.status === 'ok' && HTML_EXT.has(extOf(res.rel)) && pages.get(res.rel)?.model.isDocument) queue.push(res.rel);
409
+ }
410
+ }
411
+ return extraEntries;
412
+ }
413
+
414
+ /** Registra los recursos simples (imágenes, iconos, enlaces...) de una página. */
415
+ function collectRefs(ctx, page) {
416
+ for (const r of page.model.refs) {
417
+ const values = r.kind === 'srcset' ? parseSrcset(r.value).map((c) => c.url) : [r.value];
418
+ for (const v of values) {
419
+ const res = ctx.resolve(v, page.baseDir, { page: r.kind === 'page' });
420
+ if (res.status === 'skip') continue;
421
+ if (res.status !== 'ok') {
422
+ if (r.soft) ctx.brokenValues.add(v);
423
+ else ctx.reportBroken(page.rel, v, res);
424
+ continue;
425
+ }
426
+ if (HTML_EXT.has(extOf(res.rel))) {
427
+ ctx.pageRefs.add(res.rel);
428
+ continue;
429
+ }
430
+ ctx.registerAsset(res.rel, page.rel);
431
+ }
432
+ }
433
+ for (const l of page.model.links) {
434
+ if (l.type === 'stylesheet' || l.type === 'preload') continue;
435
+ const res = ctx.resolve(l.href.value, page.baseDir, { page: l.type === 'page' });
436
+ if (res.status === 'ok') {
437
+ if (!HTML_EXT.has(extOf(res.rel))) ctx.registerAsset(res.rel, page.rel);
438
+ } else if (res.status !== 'skip') {
439
+ if (l.type === 'asset') ctx.reportBroken(page.rel, l.href.value, res);
440
+ else ctx.brokenValues.add(l.href.value);
441
+ }
442
+ }
443
+ }
444
+
445
+ function parseImportMap(ctx, page, item) {
446
+ try {
447
+ const json = JSON.parse(item.inline.code);
448
+ return { json, imports: json.imports ?? {}, scopes: json.scopes, baseDir: page.baseDir };
449
+ } catch (err) {
450
+ ctx.warn(`el import map no es JSON válido (${err.message}); se ignora`, page.rel);
451
+ return null;
452
+ }
453
+ }
454
+
455
+ /** Agrupa los scripts clásicos consecutivos que se pueden unir sin cambiar el comportamiento. */
456
+ function classicRuns(ctx, page) {
457
+ const cfg = ctx.cfg;
458
+ const mergeOn = cfg.js.mergeClassic === 'auto' ? !cfg.sourcemap : cfg.js.mergeClassic;
459
+ const runs = [];
460
+ let current = null;
461
+ const close = () => {
462
+ if (current) runs.push(current);
463
+ current = null;
464
+ };
465
+ for (const s of page.model.scripts) {
466
+ if (s.kind !== 'classic' || !s.src) {
467
+ close();
468
+ continue;
469
+ }
470
+ const res = ctx.resolve(s.src.value, page.baseDir);
471
+ if (res.status === 'skip') {
472
+ close();
473
+ continue;
474
+ }
475
+ if (res.status !== 'ok') {
476
+ ctx.reportBroken(page.rel, s.src.value, res);
477
+ close();
478
+ continue;
479
+ }
480
+ 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);
482
+ const info = ctx.jsInfo(rel, false);
483
+ const attrs = s.attrs;
484
+ 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>');
488
+ 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');
491
+ const extra = [...attrs.keys()].filter((a) => !MERGE_ATTRS.has(a));
492
+ if (extra.length) reasons.push(`atributos ${extra.join(', ')}`);
493
+ const obf = ctx.shouldObfuscate([rel]);
494
+ const key = [attrs.has('defer'), attrs.has('nomodule'), attrs.get('charset') ?? '', info.strict, obf].join('|');
495
+ const cand = { s, rel, info, key, obf, reasons };
496
+ if (reasons.length) {
497
+ close();
498
+ runs.push({ items: [cand], names: null });
499
+ continue;
500
+ }
501
+ if (current && current.key === key && adjacentSiblings(current.items[current.items.length - 1].s, s) && !conflicts(current.names, info.top)) {
502
+ current.items.push(cand);
503
+ for (const [n, k] of info.top) if (!current.names.has(n) || k === 'lex') current.names.set(n, k);
504
+ } else {
505
+ close();
506
+ current = { key, items: [cand], names: new Map(info.top) };
507
+ }
508
+ }
509
+ close();
510
+ return runs;
511
+ }
512
+
513
+ function conflicts(names, top) {
514
+ for (const [n, k] of top) if (names.has(n) && (k === 'lex' || names.get(n) === 'lex')) return true;
515
+ return false;
516
+ }
517
+
518
+ function setIntegrity(ctx, page, item, attrName, bytes) {
519
+ const has = item.attrs.has('integrity');
520
+ const loc = item.el.sourceCodeLocation.attrs?.integrity;
521
+ const mode = ctx.cfg.sri;
522
+ if (has && loc) {
523
+ if (mode === false) page.edits.push({ start: loc.startOffset, end: loc.endOffset, text: '' });
524
+ else page.edits.push({ start: loc.startOffset, end: loc.endOffset, text: attrText('integrity', sri(bytes)) });
525
+ } else if (mode === true) {
526
+ const at = item.el.sourceCodeLocation.attrs?.[attrName];
527
+ if (at) page.edits.push({ start: at.endOffset, end: at.endOffset, text: ` ${attrText('integrity', sri(bytes))}` });
528
+ }
529
+ }
530
+
531
+ /** Ejecuta todo el proceso. mode: 'build' escribe la salida; 'analyze' solo calcula el plan. */
532
+ export async function runPipeline(cfg, opts = {}) {
533
+ const mode = opts.mode ?? 'build';
534
+ const t0 = Date.now();
535
+ if (mode === 'build') assertSafeOutDir(cfg, { force: opts.force });
536
+ const ctx = new Context(cfg, { ...opts, mode });
537
+ const log = opts.log ?? (() => {});
538
+ if (ctx.obfuscatorOptions) await ensureObfuscator(cfg.root);
539
+ if (cfg.js.minifier === 'terser' && cfg.js.minify) await ensureTerser(cfg.root);
540
+
541
+ // 1. Páginas y entradas
542
+ const pages = loadPages(ctx);
543
+ const extra = selectEntries(ctx, pages);
544
+ const fullPages = [...pages.values()].filter((p) => p.full);
545
+ const partialPages = [...pages.values()].filter((p) => !p.full);
546
+ log(`Páginas: ${fullPages.length} completas, ${partialPages.length} en modo conservador`);
547
+ const htmlBlob = [];
548
+ for (const p of fullPages) collectRefs(ctx, p);
549
+ for (const p of partialPages) collectRefs(ctx, p);
550
+
551
+ // 2. Módulos ES agrupados por import map
552
+ const groups = new Map();
553
+ const groupFor = (page, importMap) => {
554
+ const key = importMap ? `${page.baseDir}\0${JSON.stringify(importMap.json)}` : '';
555
+ if (!groups.has(key))
556
+ groups.set(key, {
557
+ key,
558
+ importMap,
559
+ hash: cfg.hash,
560
+ entries: new Set(),
561
+ virtual: new Map(),
562
+ pages: new Set(),
563
+ forcePerFile: importMap?.scopes ? 'el import map usa "scopes"' : null,
564
+ vendorSplit: !!ctx.obfuscatorOptions && !cfg.dependencies.obfuscate,
565
+ });
566
+ return groups.get(key);
567
+ };
568
+ const moduleRefs = [];
569
+ const preloadLinks = [];
570
+ for (const page of fullPages) {
571
+ 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);
573
+ let vIndex = 0;
574
+ for (const s of page.model.scripts) {
575
+ if (s.kind !== 'module' || s.inTemplate) continue;
576
+ const g = groupFor(page, map);
577
+ g.pages.add(page.rel);
578
+ if (s.src) {
579
+ const res = ctx.resolve(s.src.value, page.baseDir);
580
+ if (res.status === 'skip') continue;
581
+ if (res.status !== 'ok') {
582
+ ctx.reportBroken(page.rel, s.src.value, res);
583
+ continue;
584
+ }
585
+ g.entries.add(res.rel);
586
+ moduleRefs.push({ page, s, rel: res.rel, group: g });
587
+ } else if (s.inline && s.inline.code.trim()) {
588
+ const id = `vb-inline:${page.rel}#${vIndex++}`;
589
+ const outDir = page.baseDir ? `${page.baseDir}/` : '';
590
+ g.virtual.set(id, {
591
+ code: s.inline.code,
592
+ label: `${page.rel} (módulo inline ${vIndex})`,
593
+ baseDir: page.baseDir,
594
+ out: `${outDir}__vb_inline_${shortHash(id)}`,
595
+ page,
596
+ s,
597
+ });
598
+ }
599
+ }
600
+ for (const l of page.model.links) if (l.type === 'preload') preloadLinks.push({ page, l });
601
+ }
602
+ if (extra.modules.length) {
603
+ const g = {
604
+ key: '\0entries',
605
+ importMap: null,
606
+ hash: false,
607
+ entries: new Set(extra.modules),
608
+ virtual: new Map(),
609
+ pages: new Set(),
610
+ forcePerFile: null,
611
+ };
612
+ groups.set(g.key, g);
613
+ }
614
+ const entryOut = new Map(); // `${groupKey}\0${rel}` → outRel
615
+ const inlineModuleCode = new Map(); // virtual id → código final
616
+ for (const g of groups.values()) {
617
+ if (!g.entries.size && !g.virtual.size) continue;
618
+ const res = await buildModuleGroup(ctx, { ...g, hash: g.hash });
619
+ 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);
623
+ for (const [id, v] of g.virtual) {
624
+ const info = analyzeJs(v.code, { module: true });
625
+ if (!info.ok) continue;
626
+ ctx.noteJsInfo(v.label, info, v.baseDir);
627
+ for (const imp of info.imports) if (imp.spec) markModuleImport(ctx, imp.spec, v.baseDir, v.label, g.importMap);
628
+ const r = await processModuleStandalone(ctx, { code: v.code, id: v.label, info, obfuscate: ctx.shouldObfuscate([v.page.rel]) });
629
+ for (const n of r.notes) ctx.note(n, v.label);
630
+ inlineModuleCode.set(id, r.code);
631
+ }
632
+ continue;
633
+ }
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;
639
+ const obfRels = o.virtualId ? [g.virtual.get(o.virtualId).page.rel] : o.isChunk ? o.inputs : [o.entry];
640
+ 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
+ }
653
+ }
654
+ if (o.virtualId) {
655
+ const v = g.virtual.get(o.virtualId);
656
+ inlineModuleCode.set(o.virtualId, code.replace(/\n$/, ''));
657
+ for (const rel of o.inputs) ctx.cover(rel, 'empaquetado', `${v.page.rel} (módulo inline)`);
658
+ continue;
659
+ }
660
+ const action = o.isVendor ? 'vendor' : o.isChunk ? 'chunk' : 'bundle';
661
+ const outRel = ctx.emitCode(o.outRel, code, map, { type: 'js', module: true, action, sources, before: o.before || sumSizes(ctx, sources) });
662
+ 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);
664
+ }
665
+ for (const spec of res.vendor) if (!ctx.vendorPackages.includes(spec)) ctx.vendorPackages.push(spec);
666
+ // esbuild puede incrustar un módulo entero (p. ej. constantes) sin que figure en ninguna salida.
667
+ 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)');
669
+ }
670
+ }
671
+
672
+ // 3. CSS enlazado
673
+ const cssOut = new Map();
674
+ const cssEntries = new Set(extra.css);
675
+ for (const page of fullPages)
676
+ for (const l of page.model.links)
677
+ if (l.type === 'stylesheet') {
678
+ const res = ctx.resolve(l.href.value, page.baseDir);
679
+ if (res.status === 'ok') cssEntries.add(res.rel);
680
+ else if (res.status !== 'skip') ctx.reportBroken(page.rel, l.href.value, res);
681
+ }
682
+ for (const rel of cssEntries) {
683
+ 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 });
686
+ if (r.error) {
687
+ ctx.warn(`no se pudo procesar el CSS (${r.error}); se copia tal cual`, rel);
688
+ cssOut.set(rel, ctx.emitCopy(rel, rel, 'copy-fallback'));
689
+ ctx.cover(rel, 'copiado sin cambios (error al procesar)', rel);
690
+ continue;
691
+ }
692
+ const outRel = hashed ? ctx.hashedPath(rel, r.code, '.css') : rel;
693
+ ctx.emitCode(outRel, r.code, r.map, { type: 'css', action: 'bundle', sources: r.inputs, before: sumSizes(ctx, r.inputs) });
694
+ cssOut.set(rel, outRel);
695
+ for (const i of r.inputs) ctx.cover(i, i === rel ? 'procesado' : 'empaquetado', outRel);
696
+ }
697
+
698
+ // 4. Detección de white-space: pre (para no colapsar espacios del HTML)
699
+ let collapse = cfg.html.collapseWhitespace;
700
+ if (collapse === 'auto') {
701
+ const cssText = [...ctx.files.keys()].filter((r) => CSS_EXT.has(extOf(r))).map((r) => ctx.readText(r, { kind: 'css' }).text);
702
+ for (const p of pages.values()) htmlBlob.push(...p.model.styles.map((s) => s.code), ...p.model.styleAttrs.map((s) => s.value));
703
+ const pre = detectPreWhitespace(cssText.join('\n') + htmlBlob.join('\n'));
704
+ collapse = !pre;
705
+ if (pre)
706
+ 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)',
708
+ );
709
+ }
710
+
711
+ // 5. Scripts clásicos por página
712
+ const classicCache = new Map();
713
+ for (const page of fullPages) {
714
+ 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}`;
717
+ let out = classicCache.get(key);
718
+ if (!out) {
719
+ out = await processClassicRun(ctx, run, page);
720
+ classicCache.set(key, out);
721
+ }
722
+ if (out.merged) {
723
+ // El primer <script> apunta al archivo unido; los demás se quitan.
724
+ const first = run.items[0];
725
+ 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)) });
726
+ for (const c of run.items.slice(1)) page.edits.push({ start: c.s.start, end: c.s.end, text: '' });
727
+ } else {
728
+ run.items.forEach((c, i) => {
729
+ const o = out.outs[i];
730
+ if (o.outRel !== c.rel)
731
+ 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)) });
732
+ if (o.changed) setIntegrity(ctx, page, c.s, 'src', ctx.outputBytes(o.outRel));
733
+ });
734
+ }
735
+ 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));
736
+ }
737
+ }
738
+
739
+ // 6. Edición de cada página: módulos, CSS, inline, preload
740
+ for (const page of fullPages) {
741
+ for (const m of moduleRefs.filter((x) => x.page === page)) {
742
+ const outRel = entryOut.get(`${m.group.key}\0${m.rel}`);
743
+ if (!outRel) continue;
744
+ if (outRel !== m.rel)
745
+ page.edits.push({ start: m.s.src.start, end: m.s.src.end, text: attrText('src', urlFor(ctx, page, m.s.src.value, outRel)) });
746
+ if (ctx.outputs.has(outRel)) setIntegrity(ctx, page, m.s, 'src', ctx.outputBytes(outRel));
747
+ }
748
+ for (const l of page.model.links) {
749
+ if (l.type !== 'stylesheet') continue;
750
+ const res = ctx.resolve(l.href.value, page.baseDir);
751
+ if (res.status !== 'ok' || !cssOut.has(res.rel)) continue;
752
+ const outRel = cssOut.get(res.rel);
753
+ if (outRel !== res.rel)
754
+ page.edits.push({ start: l.href.start, end: l.href.end, text: attrText('href', urlFor(ctx, page, l.href.value, outRel)) });
755
+ setIntegrity(ctx, page, l, 'href', ctx.outputBytes(outRel));
756
+ }
757
+ for (const [id, v] of [...groups.values()].flatMap((g) => [...g.virtual.entries()])) {
758
+ if (v.page !== page || !inlineModuleCode.has(id)) continue;
759
+ page.edits.push({ start: v.s.inline.start, end: v.s.inline.end, text: escapeInlineScript(inlineModuleCode.get(id)) });
760
+ }
761
+ }
762
+ for (const page of pages.values()) await processInline(ctx, page, collapse);
763
+
764
+ // 7. Workers, archivos sueltos y rutas mencionadas (hasta que no aparezca nada nuevo)
765
+ await drainQueues(ctx, cssOut);
766
+
767
+ // 8. Archivos no referenciados
768
+ const unref = [];
769
+ for (const [rel, f] of ctx.files) {
770
+ if (ctx.coverage.has(rel) || ctx.hasOwnOutput(rel) || HTML_EXT.has(f.ext)) continue;
771
+ if (JS_EXT.has(f.ext) || CSS_EXT.has(f.ext)) unref.push(rel);
772
+ }
773
+ for (const rel of unref) {
774
+ if (ctx.coverage.has(rel)) continue;
775
+ const f = ctx.files.get(rel);
776
+ if (cfg.unreferenced === 'process' || f.forced) ctx.requireStandalone(rel, 'no referenciado (se procesa suelto)');
777
+ else if (cfg.unreferenced === 'copy') {
778
+ ctx.emitCopy(rel, rel);
779
+ ctx.cover(rel, 'no referenciado: copiado', rel);
780
+ } else ctx.cover(rel, 'no referenciado: omitido (unreferenced: skip)');
781
+ }
782
+ await drainQueues(ctx, cssOut);
783
+
784
+ // 9. Preloads y HTML final
785
+ for (const { page, l } of preloadLinks) {
786
+ const res = ctx.resolve(l.href.value, page.baseDir);
787
+ if (res.status !== 'ok') continue;
788
+ const target =
789
+ cssOut.get(res.rel) ??
790
+ [...entryOut.entries()].find(([k]) => k.endsWith(`\0${res.rel}`))?.[1] ??
791
+ (ctx.hasOwnOutput(res.rel) || ctx.assets.has(res.rel) ? res.rel : null);
792
+ if (target == null) {
793
+ 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);
795
+ } else if (target !== res.rel) {
796
+ page.edits.push({ start: l.href.start, end: l.href.end, text: attrText('href', urlFor(ctx, page, l.href.value, target)) });
797
+ }
798
+ if (target != null && l.attrs.has('integrity') && ctx.outputs.has(target)) setIntegrity(ctx, page, l, 'href', ctx.outputBytes(target));
799
+ }
800
+ for (const page of pages.values()) {
801
+ const referenced = page.full || ctx.pageRefs.has(page.rel) || ctx.mentions.has(page.rel) || ctx.files.get(page.rel).forced;
802
+ if (!referenced && cfg.unreferenced === 'skip') {
803
+ ctx.cover(page.rel, 'no referenciado: omitido (unreferenced: skip)');
804
+ continue;
805
+ }
806
+ if (!referenced && cfg.unreferenced === 'copy') {
807
+ ctx.emitCopy(page.rel, page.rel);
808
+ ctx.cover(page.rel, 'no referenciado: copiado', page.rel);
809
+ continue;
810
+ }
811
+ await finalizePage(ctx, page, collapse);
812
+ }
813
+
814
+ // 10. Recursos: todo lo demás se copia (sin perder nada)
815
+ for (const [rel, f] of ctx.files) {
816
+ if (ctx.hasOwnOutput(rel)) {
817
+ if (!ctx.coverage.has(rel)) ctx.cover(rel, 'procesado', rel);
818
+ continue;
819
+ }
820
+ const cov = ctx.coverage.get(rel);
821
+ // 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;
823
+ const referenced = ctx.assets.has(rel) || ctx.mentions.has(rel);
824
+ if (cfg.unreferenced === 'skip' && !referenced && !f.forced) {
825
+ ctx.cover(rel, 'no referenciado: omitido (unreferenced: skip)');
826
+ continue;
827
+ }
828
+ if ((f.ext === '.json' || f.ext === '.webmanifest') && cfg.json.minify) {
829
+ const raw = decodeUtf8(fs.readFileSync(f.abs));
830
+ const min = raw == null ? null : minifyJsonText(stripBom(raw).text);
831
+ if (min != null && Buffer.byteLength(min) < f.size) {
832
+ ctx.emit(rel, min, { type: 'json', action: 'minify', sources: [rel], before: f.size });
833
+ ctx.cover(rel, 'JSON minificado', rel);
834
+ continue;
835
+ }
836
+ }
837
+ ctx.emitCopy(rel, rel);
838
+ ctx.cover(rel, 'copiado', rel);
839
+ }
840
+
841
+ // 11. Avisos de rutas mencionadas en el código que ya no existirán
842
+ 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).`);
845
+ }
846
+ }
847
+ if (cfg.hash) {
848
+ for (const [rel, from] of ctx.mentions) {
849
+ 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.`);
851
+ }
852
+ }
853
+
854
+ // 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]);
856
+
857
+ const result = {
858
+ version: VERSION,
859
+ root: cfg.root,
860
+ outDir: cfg.outDir,
861
+ mode,
862
+ pages: { full: fullPages.map((p) => p.rel), conservative: partialPages.map((p) => p.rel) },
863
+ outputs: ctx.outputs,
864
+ coverage: ctx.coverage,
865
+ ignored: ctx.ignored,
866
+ broken: ctx.broken,
867
+ warnings: ctx.warnings,
868
+ notes: ctx.notes,
869
+ workers: [...ctx.workers.entries()].map(([rel, w]) => ({ rel, ...w })),
870
+ standalone: [...ctx.standalone.entries()].map(([rel, s]) => ({ rel, reason: s.reason })),
871
+ dependencies: [...ctx.files.keys()]
872
+ .filter((rel) => JS_EXT.has(extOf(rel)) && ctx.dependencyReason(rel))
873
+ .map((rel) => ({ rel, reason: ctx.dependencyReason(rel) })),
874
+ vendor: ctx.vendorPackages,
875
+ obfuscated: !!ctx.obfuscatorOptions,
876
+ report: buildReport(ctx.outputs),
877
+ verify: null,
878
+ durationMs: 0,
879
+ };
880
+ if (mode === 'analyze') {
881
+ result.durationMs = Date.now() - t0;
882
+ return result;
883
+ }
884
+
885
+ // 12. Escritura, verificación y publicación
886
+ let staging;
887
+ try {
888
+ staging = await writeStaging(cfg, ctx.outputs);
889
+ if (cfg.verify) {
890
+ result.verify = await verifyOutput(staging, ctx.outputs, ctx);
891
+ if (result.verify.errors.length) {
892
+ 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);
894
+ }
895
+ }
896
+ await writeManifest(staging, {
897
+ tool: 'vanilla-builder',
898
+ version: VERSION,
899
+ builtAt: new Date().toISOString(),
900
+ files: [...ctx.outputs.keys()].sort(),
901
+ });
902
+ await swapIntoPlace(cfg, staging);
903
+ } catch (err) {
904
+ if (staging && !opts.keepFailed) await discardStaging(staging);
905
+ throw err;
906
+ }
907
+ result.durationMs = Date.now() - t0;
908
+ return result;
909
+ }
910
+
911
+ const COMPRESS_TYPES = new Set(['html', 'css', 'js', 'json', 'svg', 'text', 'map']);
912
+
913
+ function precompress(ctx, kinds) {
914
+ for (const [rel, o] of [...ctx.outputs]) {
915
+ if (!COMPRESS_TYPES.has(o.type)) continue;
916
+ 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
+ }
933
+ }
934
+
935
+ function sumSizes(ctx, rels) {
936
+ return rels.reduce((a, r) => a + (ctx.files.get(r)?.size ?? 0), 0);
937
+ }
938
+
939
+ function markModuleImport(ctx, spec, baseDir, from, importMap) {
940
+ const c = classifyUrl(spec);
941
+ if (c.type !== 'root' && c.type !== 'relative') {
942
+ if (importMap?.imports && c.type !== 'external') {
943
+ const target = hasOwn(importMap.imports, spec)
944
+ ? importMap.imports[spec]
945
+ : Object.keys(importMap.imports)
946
+ .filter((k) => k.endsWith('/') && spec.startsWith(k))
947
+ .map((k) => importMap.imports[k] + spec.slice(k.length))[0];
948
+ if (target) {
949
+ const r = ctx.resolve(target, importMap.baseDir);
950
+ if (r.status === 'ok') ctx.requireStandalone(r.rel, `importado desde ${from}`, { module: true });
951
+ }
952
+ }
953
+ return;
954
+ }
955
+ const r = ctx.resolve(spec, baseDir);
956
+ if (r.status === 'ok') ctx.requireStandalone(r.rel, `importado desde ${from}`, { module: true });
957
+ else ctx.reportBroken(from, spec, r);
958
+ }
959
+
960
+ async function processClassicRun(ctx, run, page) {
961
+ const items = run.items;
962
+ 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
+ }
969
+ if (items.length > 1) {
970
+ // Separadores con ; y salto de línea: evitan que un archivo sin ; final se "pegue" al siguiente.
971
+ const code = items.map((c) => ctx.readText(c.rel, { kind: 'js', charset: c.s.attrs.get('charset') }).text.replace(/\s*$/, '\n')).join(';\n');
972
+ 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 });
975
+ for (const n of r.notes) ctx.note(n, `scripts unidos (${items.map((c) => c.rel).join(', ')})`);
976
+ const outRel = ctx.emitCode(`${ctx.chunkDir}/scripts-${shortHash(r.code)}.js`, r.code, null, {
977
+ type: 'js',
978
+ action: 'merge',
979
+ sources: items.map((c) => c.rel),
980
+ before: sumSizes(
981
+ ctx,
982
+ items.map((c) => c.rel),
983
+ ),
984
+ });
985
+ for (const c of items) ctx.cover(c.rel, 'unido', outRel);
986
+ return { merged: true, outRel };
987
+ }
988
+ ctx.warn(`no se pudieron unir (${info.error}); se procesan por separado`, page.rel);
989
+ }
990
+ // Un solo archivo, o una unión fallida: cada uno se procesa en su sitio.
991
+ 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 });
995
+ 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;
998
+ 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);
1000
+ outs.push({ outRel, changed: true });
1001
+ }
1002
+ return { merged: false, outs };
1003
+ }
1004
+
1005
+ /** Scripts y estilos inline, atributos style y JSON inline de una página. */
1006
+ async function processInline(ctx, page) {
1007
+ const cfg = ctx.cfg;
1008
+ for (const s of page.model.scripts) {
1009
+ if (s.src) {
1010
+ if (!page.full) {
1011
+ 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' });
1013
+ else if (res.status !== 'skip') ctx.reportBroken(page.rel, s.src.value, res);
1014
+ }
1015
+ continue;
1016
+ }
1017
+ if (!s.inline || !s.inline.code.trim()) continue;
1018
+ if (s.kind === 'classic') {
1019
+ const info = analyzeJs(s.inline.code, { module: false });
1020
+ if (!info.ok) {
1021
+ ctx.note(`un <script> inline no es JS estándar (¿plantilla?); se deja igual`, page.rel);
1022
+ continue;
1023
+ }
1024
+ ctx.noteJsInfo(page.rel, info, page.baseDir);
1025
+ const r = await processClassic(ctx, { code: s.inline.code, id: `${page.rel}#inline`, info, obfuscate: ctx.shouldObfuscate([page.rel]) });
1026
+ for (const n of r.notes) ctx.note(`script inline: ${n}`, page.rel);
1027
+ if (r.code !== s.inline.code)
1028
+ page.edits.push({ start: s.inline.start, end: s.inline.end, text: escapeInlineScript(r.code.replace(/\n$/, '')) });
1029
+ } else if (s.kind === 'module' && !page.full) {
1030
+ const info = analyzeJs(s.inline.code, { module: true });
1031
+ if (!info.ok) continue;
1032
+ ctx.noteJsInfo(page.rel, info, page.baseDir);
1033
+ for (const imp of info.imports) if (imp.spec) markModuleImport(ctx, imp.spec, page.baseDir, page.rel, null);
1034
+ const r = await processModuleStandalone(ctx, {
1035
+ code: s.inline.code,
1036
+ id: `${page.rel}#module`,
1037
+ info,
1038
+ obfuscate: ctx.shouldObfuscate([page.rel]),
1039
+ });
1040
+ if (r.code !== s.inline.code)
1041
+ page.edits.push({ start: s.inline.start, end: s.inline.end, text: escapeInlineScript(r.code.replace(/\n$/, '')) });
1042
+ } else if ((s.kind === 'json' || s.kind === 'importmap') && cfg.json.minify) {
1043
+ const min = minifyJsonText(s.inline.code.trim());
1044
+ if (min != null && min !== s.inline.code) page.edits.push({ start: s.inline.start, end: s.inline.end, text: escapeInlineScript(min) });
1045
+ }
1046
+ }
1047
+ if (!page.full) {
1048
+ for (const l of page.model.links) {
1049
+ if (l.type !== 'stylesheet') continue;
1050
+ const res = ctx.resolve(l.href.value, page.baseDir);
1051
+ if (res.status === 'ok') ctx.requireStandalone(res.rel, `hoja de estilos en ${page.rel}`);
1052
+ else if (res.status !== 'skip') ctx.reportBroken(page.rel, l.href.value, res);
1053
+ }
1054
+ }
1055
+ if (cfg.css.minify) {
1056
+ for (const st of page.model.styles) {
1057
+ if (!st.code.trim()) continue;
1058
+ const r = await buildCss(ctx, {
1059
+ inline: { code: st.code, baseDir: page.baseDir, label: `${page.rel} (<style>)` },
1060
+ outRel: page.baseDir ? `${page.baseDir}/__page.html` : '__page.html',
1061
+ });
1062
+ if (r.error) {
1063
+ ctx.note(`un <style> no se pudo minificar (${r.error.split('\n')[0]}); se deja igual`, page.rel);
1064
+ continue;
1065
+ }
1066
+ if (r.code !== st.code) page.edits.push({ start: st.start, end: st.end, text: escapeInlineStyle(r.code) });
1067
+ }
1068
+ for (const a of page.model.styleAttrs) {
1069
+ const min = await minifyStyleAttr(a.value, cfg.css.target);
1070
+ if (min !== a.value) page.edits.push({ start: a.start, end: a.end, text: attrText('style', min) });
1071
+ }
1072
+ }
1073
+ }
1074
+
1075
+ async function finalizePage(ctx, page, collapse) {
1076
+ let text;
1077
+ try {
1078
+ text = applyEdits(page.text, page.edits);
1079
+ } catch (err) {
1080
+ throw new BuildError(`Error interno al editar ${page.rel}: ${err.message}`);
1081
+ }
1082
+ const cfg = ctx.cfg;
1083
+ const notes = [];
1084
+ if (cfg.html.minify && page.encoding !== 'latin1') {
1085
+ const r = await minifyHtml(text, { removeComments: cfg.html.removeComments, collapseWhitespace: collapse });
1086
+ if (r.note) notes.push(r.note);
1087
+ text = r.code;
1088
+ }
1089
+ for (const n of notes) ctx.note(n, page.rel);
1090
+ const data = page.encoding === 'latin1' ? Buffer.from(text, 'latin1') : Buffer.from((page.bom ? BOM : '') + text, 'utf8');
1091
+ ctx.emit(page.rel, data, {
1092
+ type: 'html',
1093
+ action: page.full ? 'html' : 'html-conservative',
1094
+ sources: [page.rel],
1095
+ before: ctx.files.get(page.rel).size,
1096
+ encoding: page.encoding === 'latin1' ? 'latin1' : 'utf8',
1097
+ baseDir: page.baseDir,
1098
+ baseExternal: page.baseExternal,
1099
+ });
1100
+ ctx.cover(page.rel, page.full ? 'procesado' : 'procesado (modo conservador)', page.rel);
1101
+ }
1102
+
1103
+ /** Procesa workers y archivos que deben existir sueltos en su ruta original, hasta agotar la cola. */
1104
+ async function drainQueues(ctx, cssOut) {
1105
+ for (let round = 0; round < 50; round++) {
1106
+ let progressed = false;
1107
+ // Rutas mencionadas como texto (precache de service workers, fetch...) que fueron empaquetadas.
1108
+ for (const [rel] of ctx.mentions) {
1109
+ const ext = extOf(rel);
1110
+ 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)}`);
1112
+ else if (!JS_EXT.has(ext) && !CSS_EXT.has(ext) && !HTML_EXT.has(ext)) ctx.registerAsset(rel, ctx.mentions.get(rel));
1113
+ }
1114
+ for (const [rel, w] of ctx.workers) {
1115
+ if (ctx.done.has(`w:${rel}`)) continue;
1116
+ ctx.done.add(`w:${rel}`);
1117
+ progressed = true;
1118
+ if (ctx.hasOwnOutput(rel)) continue;
1119
+ if (w.module) {
1120
+ const r = await buildModuleWorker(ctx, rel);
1121
+ if (r.error) {
1122
+ ctx.warn(`no se pudo empaquetar el worker (${r.error.split('\n')[0]}); se procesa suelto`, rel);
1123
+ ctx.requireStandalone(rel, 'worker (respaldo)', { module: true });
1124
+ continue;
1125
+ }
1126
+ let code = r.code;
1127
+ if (ctx.shouldObfuscate([rel])) {
1128
+ const o = obfuscateModule(ctx, code, rel);
1129
+ if (o.note) ctx.warn(o.note, rel);
1130
+ code = o.code;
1131
+ } else code = await terserPass(ctx, code, { isModule: true, level: 2020 });
1132
+ const sources = [...r.consumed];
1133
+ ctx.emitCode(rel, code, code === r.code ? r.map : null, {
1134
+ type: 'js',
1135
+ module: true,
1136
+ action: 'worker',
1137
+ sources,
1138
+ before: sumSizes(ctx, sources),
1139
+ });
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 });
1142
+ }
1143
+ for (const [rel, s] of ctx.standalone) {
1144
+ if (ctx.done.has(`s:${rel}`)) continue;
1145
+ ctx.done.add(`s:${rel}`);
1146
+ progressed = true;
1147
+ if (ctx.hasOwnOutput(rel) || !ctx.files.has(rel)) continue;
1148
+ await processStandalone(ctx, rel, s, cssOut);
1149
+ }
1150
+ if (!progressed) return;
1151
+ }
1152
+ throw new BuildError('Demasiadas rondas al resolver dependencias (¿ciclo inesperado?).');
1153
+ }
1154
+
1155
+ async function processStandalone(ctx, rel, s, cssOut) {
1156
+ const ext = extOf(rel);
1157
+ const dir = path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel);
1158
+ 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;
1170
+ }
1171
+ if (!JS_EXT.has(ext)) {
1172
+ ctx.registerAsset(rel, s.reason);
1173
+ return;
1174
+ }
1175
+ const auto = ctx.jsInfo(rel, 'auto');
1176
+ const isModule = !s.classic && (s.module || (auto.ok && (auto.hasModuleSyntax || auto.hasImportMeta)) || (!auto.ok && auto.isModule));
1177
+ const info = isModule ? ctx.jsInfo(rel, true) : ctx.jsInfo(rel, false);
1178
+ if (!info.ok) {
1179
+ ctx.warn(`no se pudo analizar (${info.error}); se copia sin cambios`, rel);
1180
+ ctx.emitCopy(rel, rel, 'copy-fallback');
1181
+ ctx.cover(rel, 'copiado sin cambios (no se pudo analizar)', rel);
1182
+ return;
1183
+ }
1184
+ ctx.noteJsInfo(rel, info, dir);
1185
+ const { text } = ctx.readText(rel, { kind: 'js' });
1186
+ const obfuscate = ctx.shouldObfuscate([rel]);
1187
+ let r;
1188
+ if (isModule) {
1189
+ for (const imp of info.imports) if (imp.spec) markModuleImport(ctx, imp.spec, dir, rel, null);
1190
+ for (const u of info.urls) {
1191
+ const res = ctx.resolve(u.spec, dir);
1192
+ if (res.status === 'ok') ctx.urlTarget(res.rel, rel);
1193
+ else ctx.reportBroken(rel, u.spec, res);
1194
+ }
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
+ }
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);
1202
+ }