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/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.es.md +231 -0
- package/README.md +232 -2
- package/bin/builder.js +471 -0
- package/package.json +83 -4
- package/src/builder.js +1385 -0
- package/src/compress.js +44 -0
- package/src/config.js +197 -0
- package/src/css.js +166 -0
- package/src/deps.js +199 -0
- package/src/files.js +159 -0
- package/src/html.js +446 -0
- package/src/index.js +22 -0
- package/src/js.js +584 -0
- package/src/jsimports.js +54 -0
- package/src/jsinfo.js +306 -0
- package/src/obfuscate.js +160 -0
- package/src/optional.js +38 -0
- package/src/output.js +100 -0
- package/src/pool.js +341 -0
- package/src/report.js +74 -0
- package/src/server.js +124 -0
- package/src/sourcemap.js +77 -0
- package/src/tersercore.js +33 -0
- package/src/urls.js +108 -0
- package/src/util.js +155 -0
- package/src/verify.js +138 -0
- package/src/watch.js +60 -0
- package/src/worker.js +26 -0
package/src/js.js
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
// JavaScript: módulos (empaquetado con esbuild), scripts clásicos y archivos sueltos.
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import * as esbuild from 'esbuild';
|
|
5
|
+
import * as acorn from 'acorn';
|
|
6
|
+
import * as walk from 'acorn-walk';
|
|
7
|
+
import { analyzeJs, compareGlobals } from './jsinfo.js';
|
|
8
|
+
import { importOptional } from './optional.js';
|
|
9
|
+
import { replaceWithMap } from './sourcemap.js';
|
|
10
|
+
import { relFrom, toPosix, BuildError, rootPrefix, hasOwn, shortHash, jsOutPath } from './util.js';
|
|
11
|
+
import { classifyUrl } from './urls.js';
|
|
12
|
+
import { formatMessage, esbuildErrorText } from './css.js';
|
|
13
|
+
|
|
14
|
+
/** Marcador de "raíz de salida" que se sustituye por la ruta relativa real de cada archivo generado. */
|
|
15
|
+
export const MARK = '__VB_ROOT__';
|
|
16
|
+
|
|
17
|
+
let maxEsYear = null;
|
|
18
|
+
async function maxEsTarget() {
|
|
19
|
+
if (maxEsYear) return maxEsYear;
|
|
20
|
+
maxEsYear = 2020;
|
|
21
|
+
for (let y = 2021; y <= 2035; y++) {
|
|
22
|
+
try {
|
|
23
|
+
await esbuild.transform('', { target: `es${y}` });
|
|
24
|
+
maxEsYear = y;
|
|
25
|
+
} catch {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return maxEsYear;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Target de esbuild equivalente al nivel de sintaxis del código: ni transpila ni introduce sintaxis nueva. */
|
|
33
|
+
export async function targetForLevel(level, min = 0) {
|
|
34
|
+
const l = level == null ? null : Math.max(level, min);
|
|
35
|
+
if (l == null) return 'esnext';
|
|
36
|
+
if (l === 5) return 'es5';
|
|
37
|
+
return l > (await maxEsTarget()) ? 'esnext' : `es${l}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function esbuildSourcemap(cfg) {
|
|
41
|
+
return cfg.sourcemap ? 'external' : false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// esbuild quita en silencio los atributos de import (with { type }) con targets <= es2025.
|
|
45
|
+
const SUPPORTED = { 'import-attributes': true };
|
|
46
|
+
|
|
47
|
+
const firstLine = (err) => esbuildErrorText(err, '').split('\n')[0];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Minifica (y opcionalmente ofusca) un script clásico sin alterar sus variables globales.
|
|
51
|
+
* Si una transformación cambia las globales se prueba una más conservadora; en último caso se deja el original.
|
|
52
|
+
*/
|
|
53
|
+
export async function processClassic(ctx, { code, id, info, obfuscate, sourcemap = false }) {
|
|
54
|
+
const js = ctx.cfg.js;
|
|
55
|
+
const notes = [];
|
|
56
|
+
let out = code;
|
|
57
|
+
let map = null;
|
|
58
|
+
if (js.minify) {
|
|
59
|
+
const auto = await targetForLevel(info.level);
|
|
60
|
+
const attempts = [{ target: js.target === 'auto' ? auto : js.target, keepNames: js.keepNames, label: 'completa' }];
|
|
61
|
+
if (js.target !== 'auto' || js.keepNames) attempts.push({ target: auto, keepNames: false, label: 'sin transpilar' });
|
|
62
|
+
attempts.push({ target: auto, keepNames: false, whitespace: true, label: 'solo espacios' });
|
|
63
|
+
let done = false;
|
|
64
|
+
for (const a of attempts) {
|
|
65
|
+
try {
|
|
66
|
+
const r = await esbuild.transform(code, {
|
|
67
|
+
loader: 'js',
|
|
68
|
+
target: a.target,
|
|
69
|
+
charset: 'ascii',
|
|
70
|
+
supported: SUPPORTED,
|
|
71
|
+
legalComments: js.legalComments,
|
|
72
|
+
drop: js.drop,
|
|
73
|
+
define: js.define,
|
|
74
|
+
keepNames: a.keepNames,
|
|
75
|
+
sourcemap: sourcemap ? 'external' : false,
|
|
76
|
+
sourcefile: id,
|
|
77
|
+
...(a.whitespace ? { minifyWhitespace: true } : { minify: true }),
|
|
78
|
+
logLevel: 'silent',
|
|
79
|
+
});
|
|
80
|
+
const cmp = compareGlobals(info.top, r.code);
|
|
81
|
+
if (cmp.ok) {
|
|
82
|
+
out = r.code;
|
|
83
|
+
map = r.map || null;
|
|
84
|
+
done = true;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
notes.push(`minificación ${a.label} descartada: ${cmp.reason}`);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
notes.push(`minificación ${a.label} falló: ${firstLine(err)}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!done) notes.push('se deja sin minificar para no romperlo');
|
|
93
|
+
}
|
|
94
|
+
if (obfuscate) {
|
|
95
|
+
const o = await ctx.heavy.obfuscate(out, { isModule: false, options: ctx.obfuscatorOptions, id, globals: info.top });
|
|
96
|
+
if (o.code) {
|
|
97
|
+
out = o.code;
|
|
98
|
+
map = null;
|
|
99
|
+
} else notes.push(`no se ofuscó: ${o.reason}`);
|
|
100
|
+
} else if (out !== code) {
|
|
101
|
+
const t = await terserPass(ctx, out, { isModule: false, level: info.level, globals: info.top });
|
|
102
|
+
if (t !== out) {
|
|
103
|
+
out = t;
|
|
104
|
+
map = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { code: out, map, notes };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Minifica un módulo ES sin empaquetar (sus import se quedan tal cual). */
|
|
111
|
+
export async function processModuleStandalone(ctx, { code, id, info, obfuscate, sourcemap = false }) {
|
|
112
|
+
const js = ctx.cfg.js;
|
|
113
|
+
const notes = [];
|
|
114
|
+
let out = code;
|
|
115
|
+
let map = null;
|
|
116
|
+
if (js.minify) {
|
|
117
|
+
try {
|
|
118
|
+
const r = await esbuild.transform(code, {
|
|
119
|
+
loader: 'js',
|
|
120
|
+
format: 'esm',
|
|
121
|
+
target: js.target === 'auto' ? await targetForLevel(info.level, 2015) : js.target,
|
|
122
|
+
charset: 'ascii',
|
|
123
|
+
supported: SUPPORTED,
|
|
124
|
+
legalComments: js.legalComments,
|
|
125
|
+
drop: js.drop,
|
|
126
|
+
define: js.define,
|
|
127
|
+
keepNames: js.keepNames,
|
|
128
|
+
minify: true,
|
|
129
|
+
sourcemap: sourcemap ? 'external' : false,
|
|
130
|
+
sourcefile: id,
|
|
131
|
+
logLevel: 'silent',
|
|
132
|
+
});
|
|
133
|
+
out = r.code;
|
|
134
|
+
map = r.map || null;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
notes.push(`no se pudo minificar: ${firstLine(err)}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (obfuscate) {
|
|
140
|
+
const o = await ctx.heavy.obfuscate(out, { isModule: true, options: ctx.obfuscatorOptions, id });
|
|
141
|
+
if (o.code) {
|
|
142
|
+
out = o.code;
|
|
143
|
+
map = null;
|
|
144
|
+
} else notes.push(`no se ofuscó: ${o.reason}`);
|
|
145
|
+
} else if (out !== code) {
|
|
146
|
+
const t = await terserPass(ctx, out, { isModule: true, level: info.level });
|
|
147
|
+
if (t !== out) {
|
|
148
|
+
out = t;
|
|
149
|
+
map = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { code: out, map, notes };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Ofusca un módulo ya generado (paquete, chunk o worker). */
|
|
156
|
+
export async function obfuscateModule(ctx, code, id) {
|
|
157
|
+
const o = await ctx.heavy.obfuscate(code, { isModule: true, options: ctx.obfuscatorOptions, id });
|
|
158
|
+
return o.code ? { code: o.code } : { code, note: `no se ofuscó: ${o.reason}` };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Aplica un import map (solo "imports"; con "scopes" se usa el modo sin empaquetar). */
|
|
162
|
+
export function mapSpecifier(ctx, importMap, spec, importerDir) {
|
|
163
|
+
if (!importMap?.imports) return null;
|
|
164
|
+
const imports = importMap.imports;
|
|
165
|
+
const isUrlLike = spec.startsWith('/') || spec.startsWith('./') || spec.startsWith('../');
|
|
166
|
+
let target = null;
|
|
167
|
+
if (!isUrlLike) {
|
|
168
|
+
if (hasOwn(imports, spec)) target = imports[spec];
|
|
169
|
+
else {
|
|
170
|
+
const key = Object.keys(imports)
|
|
171
|
+
.filter((k) => k.endsWith('/') && spec.startsWith(k))
|
|
172
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
173
|
+
if (key) target = imports[key] + spec.slice(key.length);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
const wanted = path.posix.normalize(spec.startsWith('/') ? spec.slice(1) : path.posix.join(importerDir, spec));
|
|
177
|
+
for (const [k, v] of Object.entries(imports)) {
|
|
178
|
+
if (!(k.startsWith('/') || k.startsWith('./') || k.startsWith('../'))) continue;
|
|
179
|
+
const kn = path.posix.normalize(k.startsWith('/') ? k.slice(1) : path.posix.join(importMap.baseDir, k));
|
|
180
|
+
if (kn === wanted) target = v;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (target == null) return null;
|
|
184
|
+
const c = classifyUrl(target);
|
|
185
|
+
if (c.type === 'external') return { external: true };
|
|
186
|
+
const r = ctx.resolve(target, importMap.baseDir);
|
|
187
|
+
if (r.status === 'ok') return { rel: r.rel };
|
|
188
|
+
// Paquetes de npm servidos desde node_modules: no se publican, pero existen en disco y se empaquetan.
|
|
189
|
+
if ((r.status === 'missing' || r.status === 'ignored') && r.rel && /(^|\/)node_modules\//.test(r.rel)) {
|
|
190
|
+
const abs = ctx.abs(r.rel);
|
|
191
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return { abs };
|
|
192
|
+
}
|
|
193
|
+
return { broken: r, target };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const EXTERNAL_SPEC = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
197
|
+
|
|
198
|
+
function isFile(abs) {
|
|
199
|
+
try {
|
|
200
|
+
return fs.statSync(abs).isFile();
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function modulePlugin(ctx, group, state) {
|
|
207
|
+
const isDepPath = (abs) => {
|
|
208
|
+
if (!abs) return false;
|
|
209
|
+
const rel = relFrom(ctx.root, abs);
|
|
210
|
+
if (rel == null || /(^|\/)node_modules\//.test(toPosix(abs))) return true;
|
|
211
|
+
return !!ctx.dependencyReason(rel);
|
|
212
|
+
};
|
|
213
|
+
const prepare = (code, id, baseDir, local) => {
|
|
214
|
+
let info = state.analysis.get(id);
|
|
215
|
+
if (!info) {
|
|
216
|
+
info = analyzeJs(code, { module: true });
|
|
217
|
+
state.analysis.set(id, info);
|
|
218
|
+
}
|
|
219
|
+
if (!info.ok) return code;
|
|
220
|
+
state.levels.push(info.level ?? 9999);
|
|
221
|
+
if (!local) return code;
|
|
222
|
+
for (const u of info.unsafeUrls) state.unsafe.push(`new URL(variable, import.meta.url) en ${id}: ${u}`);
|
|
223
|
+
for (const u of info.unsafeImports) state.unsafe.push(`import() con una ruta calculada en ${id}: ${u}`);
|
|
224
|
+
if (state.mode === 'scan') ctx.noteJsInfo(id, info, baseDir, { module: true });
|
|
225
|
+
if (!info.urls.length) return code;
|
|
226
|
+
let out = '';
|
|
227
|
+
let last = 0;
|
|
228
|
+
for (const u of [...info.urls].sort((a, b) => a.start - b.start)) {
|
|
229
|
+
const r = ctx.resolve(u.spec, baseDir);
|
|
230
|
+
if (r.status !== 'ok') {
|
|
231
|
+
if (state.mode === 'scan') ctx.reportBroken(id, u.spec, r);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const target = ctx.urlTarget(r.rel, id);
|
|
235
|
+
out += code.slice(last, u.start) + JSON.stringify(`${MARK}/${target}${r.info.suffix}`);
|
|
236
|
+
last = u.end;
|
|
237
|
+
}
|
|
238
|
+
return out + code.slice(last);
|
|
239
|
+
};
|
|
240
|
+
return {
|
|
241
|
+
name: 'vanilla-builder-js',
|
|
242
|
+
setup(b) {
|
|
243
|
+
b.onResolve({ filter: /^vb-inline:/ }, (a) => ({ path: a.path, namespace: 'vb-inline' }));
|
|
244
|
+
b.onLoad({ filter: /.*/, namespace: 'vb-inline' }, (a) => {
|
|
245
|
+
const v = group.virtual.get(a.path);
|
|
246
|
+
return { contents: prepare(v.code, v.label, v.baseDir, true), resolveDir: ctx.abs(v.baseDir), loader: 'js' };
|
|
247
|
+
});
|
|
248
|
+
b.onResolve({ filter: /.*/ }, async (a) => {
|
|
249
|
+
if (a.kind === 'entry-point') return undefined;
|
|
250
|
+
const r = resolveSpecial(a);
|
|
251
|
+
// Separación de dependencias: lo que el código propio importa de una librería va a su propio archivo vendor.
|
|
252
|
+
if (!state.vendor || a.pluginData?.vbInner || r?.external || r?.errors) return r;
|
|
253
|
+
if (a.namespace !== 'vb-inline' && isDepPath(a.importer)) return r;
|
|
254
|
+
let abs = r?.path;
|
|
255
|
+
// Ruta relativa a un archivo que existe: se resuelve aquí, sin otro viaje de ida y vuelta a esbuild.
|
|
256
|
+
if (!abs && /^\.\.?\//.test(a.path) && a.namespace !== 'vb-inline' && a.resolveDir) {
|
|
257
|
+
const direct = path.resolve(a.resolveDir, a.path);
|
|
258
|
+
if (path.extname(direct) && isFile(direct)) abs = direct;
|
|
259
|
+
}
|
|
260
|
+
if (!abs) {
|
|
261
|
+
const res = await b.resolve(a.path, {
|
|
262
|
+
kind: a.kind,
|
|
263
|
+
resolveDir: a.resolveDir,
|
|
264
|
+
importer: a.namespace === 'vb-inline' ? undefined : a.importer,
|
|
265
|
+
pluginData: { vbInner: true },
|
|
266
|
+
});
|
|
267
|
+
if (res.errors.length || res.external) return undefined;
|
|
268
|
+
abs = res.path;
|
|
269
|
+
}
|
|
270
|
+
if (!isDepPath(abs)) return r;
|
|
271
|
+
if (state.mode === 'scan') {
|
|
272
|
+
if (!state.vendor.has(abs)) state.vendor.set(abs, a.path);
|
|
273
|
+
return r;
|
|
274
|
+
}
|
|
275
|
+
const out = state.vendorOut?.get(abs);
|
|
276
|
+
return out ? { path: `${MARK}/${out}`, external: true } : r;
|
|
277
|
+
});
|
|
278
|
+
const resolveSpecial = (a) => {
|
|
279
|
+
const spec = a.path;
|
|
280
|
+
if (EXTERNAL_SPEC.test(spec)) return { path: spec, external: true };
|
|
281
|
+
const importer = a.namespace === 'vb-inline' ? group.virtual.get(a.importer)?.label : relFrom(ctx.root, a.importer);
|
|
282
|
+
const baseDir = relFrom(ctx.root, a.resolveDir) ?? '';
|
|
283
|
+
if (a.with?.type === 'css') {
|
|
284
|
+
// CSS module script: el navegador espera un CSSStyleSheet, así que el CSS va aparte.
|
|
285
|
+
const r = ctx.resolve(spec, baseDir);
|
|
286
|
+
if (r.status !== 'ok') {
|
|
287
|
+
ctx.reportBroken(importer, spec, r);
|
|
288
|
+
return { path: spec, external: true };
|
|
289
|
+
}
|
|
290
|
+
ctx.requireStandalone(r.rel, `importado como CSS desde ${importer}`);
|
|
291
|
+
return { path: `${MARK}/${r.rel}`, external: true };
|
|
292
|
+
}
|
|
293
|
+
const mapped = mapSpecifier(ctx, group.importMap, spec, baseDir);
|
|
294
|
+
if (mapped?.external) return { path: spec, external: true };
|
|
295
|
+
if (mapped?.rel) return { path: ctx.abs(mapped.rel) };
|
|
296
|
+
if (mapped?.abs) return { path: mapped.abs };
|
|
297
|
+
if (mapped?.broken) return { errors: [{ text: `El import map de la página apunta "${spec}" a "${mapped.target}", que no existe.` }] };
|
|
298
|
+
if (spec.startsWith('/')) {
|
|
299
|
+
const r = ctx.resolve(spec, baseDir);
|
|
300
|
+
if (r.status === 'ok') return { path: ctx.abs(r.rel) };
|
|
301
|
+
return { errors: [{ text: `No existe "${spec}" (importado desde ${importer}).` }] };
|
|
302
|
+
}
|
|
303
|
+
return undefined;
|
|
304
|
+
};
|
|
305
|
+
b.onLoad({ filter: /\.(?:m?js|cjs|m?ts|cts)$/i }, (a) => {
|
|
306
|
+
const rel = relFrom(ctx.root, a.path);
|
|
307
|
+
const local = rel != null && !/(^|\/)node_modules\//.test(rel) && ctx.files.has(rel);
|
|
308
|
+
// Los .ts propios llegan ya transpilados (readText); los de node_modules los transpila esbuild.
|
|
309
|
+
const isTs = /\.(?:m?ts|cts)$/i.test(a.path);
|
|
310
|
+
if (!local && isTs) return { contents: fs.readFileSync(a.path, 'utf8'), loader: 'ts' };
|
|
311
|
+
const code = local ? ctx.readText(rel, { kind: 'js' }).text : fs.readFileSync(a.path, 'utf8');
|
|
312
|
+
if (local) state.consumed.add(rel);
|
|
313
|
+
return { contents: prepare(code, local ? rel : toPosix(a.path), local ? path.posix.dirname(rel) : '', local), loader: 'js' };
|
|
314
|
+
});
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function newState(mode, vendor = false) {
|
|
320
|
+
return { mode, analysis: new Map(), levels: [], unsafe: [], consumed: new Set(), vendor: vendor ? new Map() : null, vendorOut: null };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let terser = null;
|
|
324
|
+
|
|
325
|
+
/** Carga terser (opcional) para la compresión máxima. */
|
|
326
|
+
export async function ensureTerser(projectRoot) {
|
|
327
|
+
terser = await importOptional('terser', projectRoot, 'para la compresión máxima (js.minifier: "terser")');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Segunda pasada con terser (varias pasadas de compresión). Solo se acepta si el resultado
|
|
332
|
+
* es más pequeño y conserva globales (scripts) o exportaciones/importaciones (módulos).
|
|
333
|
+
*/
|
|
334
|
+
export async function terserPass(ctx, code, { isModule, level, globals }) {
|
|
335
|
+
const js = ctx.cfg.js;
|
|
336
|
+
if (!terser || js.minifier !== 'terser' || !js.minify || ctx.cfg.sourcemap) return code;
|
|
337
|
+
return ctx.heavy.terser(code, { isModule, level, globals: isModule ? undefined : globals, keepNames: js.keepNames });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Bytes de origen que aportaron código a una salida (incluye paquetes de node_modules). */
|
|
341
|
+
function inputBytes(metafile, meta) {
|
|
342
|
+
return Object.keys(meta.inputs ?? {}).reduce((sum, k) => sum + (metafile.inputs[k]?.bytes ?? 0), 0);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function levelOf(target) {
|
|
346
|
+
const m = /^es(\d{4})$/.exec(target);
|
|
347
|
+
return m ? Number(m[1]) : target === 'es5' ? 5 : 2020;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Empaqueta las librerías importadas por el código propio en archivos vendor (sin ofuscar). */
|
|
351
|
+
async function buildVendor(ctx, group, common, vendor, target) {
|
|
352
|
+
const chunkDir = ctx.cfg.js.chunkDir.replace(/\/+$/, '');
|
|
353
|
+
const names = new Set();
|
|
354
|
+
const entryPoints = [];
|
|
355
|
+
for (const [abs, spec] of vendor) {
|
|
356
|
+
const bare = !/^[./]/.test(spec) && !EXTERNAL_SPEC.test(spec);
|
|
357
|
+
const base = (bare ? spec : path.basename(abs))
|
|
358
|
+
.replace(/\.[cm]?js$/i, '')
|
|
359
|
+
.replace(/^@/, '')
|
|
360
|
+
.replace(/[^\w.-]+/g, '_');
|
|
361
|
+
const name = names.has(base) ? `${base}-${shortHash(abs, 6)}` : base;
|
|
362
|
+
names.add(name);
|
|
363
|
+
entryPoints.push({ in: abs, out: `${chunkDir}/vendor/${name}` });
|
|
364
|
+
}
|
|
365
|
+
const st = newState('emit');
|
|
366
|
+
const res = await esbuild.build({
|
|
367
|
+
...common,
|
|
368
|
+
entryPoints,
|
|
369
|
+
entryNames: group.hash ? '[dir]/[name]-[hash]' : '[dir]/[name]',
|
|
370
|
+
chunkNames: `${chunkDir}/vendor/[name]-[hash]`,
|
|
371
|
+
target,
|
|
372
|
+
minify: ctx.cfg.js.minify,
|
|
373
|
+
keepNames: ctx.cfg.js.keepNames,
|
|
374
|
+
drop: ctx.cfg.js.drop,
|
|
375
|
+
sourcemap: esbuildSourcemap(ctx.cfg),
|
|
376
|
+
plugins: [modulePlugin(ctx, { importMap: group.importMap, virtual: new Map() }, st)],
|
|
377
|
+
});
|
|
378
|
+
const outdir = common.outdir;
|
|
379
|
+
const outByAbs = new Map();
|
|
380
|
+
const maps = new Map();
|
|
381
|
+
const outputs = [];
|
|
382
|
+
for (const f of res.outputFiles) {
|
|
383
|
+
const outRel = toPosix(path.relative(outdir, f.path));
|
|
384
|
+
if (outRel.endsWith('.map')) maps.set(outRel.slice(0, -4), f.text);
|
|
385
|
+
}
|
|
386
|
+
for (const f of res.outputFiles) {
|
|
387
|
+
const outRel = toPosix(path.relative(outdir, f.path));
|
|
388
|
+
if (outRel.endsWith('.map')) continue;
|
|
389
|
+
const meta = res.metafile.outputs[toPosix(path.relative(ctx.root, f.path))] ?? {};
|
|
390
|
+
if (meta.entryPoint) outByAbs.set(path.resolve(ctx.root, meta.entryPoint), outRel);
|
|
391
|
+
let code = f.text.replace(/\n\/\/# sourceMappingURL=.*\n?$/, '\n');
|
|
392
|
+
let map = maps.get(outRel) ?? null;
|
|
393
|
+
({ code, map } = finalizeMarkers(code, map, outRel));
|
|
394
|
+
const srcInputs = Object.keys(meta.inputs ?? {})
|
|
395
|
+
.map((p) => relFrom(ctx.root, path.resolve(ctx.root, p)))
|
|
396
|
+
.filter((rel) => rel && ctx.files.has(rel));
|
|
397
|
+
const before = inputBytes(res.metafile, meta);
|
|
398
|
+
outputs.push({ outRel, code, map, entry: null, virtualId: null, inputs: srcInputs, isChunk: !meta.entryPoint, isVendor: true, before });
|
|
399
|
+
}
|
|
400
|
+
return { outByAbs, outputs, consumed: st.consumed };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Reescribe los import relativos de un módulo como si estuviera en `toFile` en lugar de `fromFile`. */
|
|
404
|
+
export function rebaseImports(code, fromFile, toFile) {
|
|
405
|
+
const fromDir = path.posix.dirname(fromFile);
|
|
406
|
+
const toDir = path.posix.dirname(toFile);
|
|
407
|
+
if (fromDir === toDir) return code;
|
|
408
|
+
const ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module' });
|
|
409
|
+
const spans = [];
|
|
410
|
+
walk.full(ast, (n) => {
|
|
411
|
+
const src =
|
|
412
|
+
(n.type === 'ImportDeclaration' || n.type === 'ExportAllDeclaration' || n.type === 'ExportNamedDeclaration' || n.type === 'ImportExpression') &&
|
|
413
|
+
n.source;
|
|
414
|
+
if (src && src.type === 'Literal' && typeof src.value === 'string' && /^\.\.?\//.test(src.value)) spans.push(src);
|
|
415
|
+
});
|
|
416
|
+
let out = code;
|
|
417
|
+
for (const s of spans.sort((a, b) => b.start - a.start)) {
|
|
418
|
+
const target = path.posix.normalize(path.posix.join(fromDir, s.value));
|
|
419
|
+
let rel = path.posix.relative(toDir, target);
|
|
420
|
+
if (!rel.startsWith('.')) rel = `./${rel}`;
|
|
421
|
+
out = out.slice(0, s.start) + JSON.stringify(rel) + out.slice(s.end);
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Sustituye el marcador de raíz por la ruta relativa real (manteniendo el source map alineado). */
|
|
427
|
+
export function finalizeMarkers(code, map, outRel) {
|
|
428
|
+
return replaceWithMap(code, map, `${MARK}/`, `${rootPrefix(outRel)}/`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Empaqueta un grupo de módulos (todas las páginas que comparten import map).
|
|
433
|
+
* @returns {Promise<{mode:'bundle'|'per-file', reason?: string, outputs?: Array, consumed: Set<string>, inputs: string[]}>}
|
|
434
|
+
*/
|
|
435
|
+
export async function buildModuleGroup(ctx, group) {
|
|
436
|
+
const cfg = ctx.cfg;
|
|
437
|
+
const entryPoints = [...[...group.entries].map((rel) => ctx.abs(rel)), ...[...group.virtual.entries()].map(([id, v]) => ({ in: id, out: v.out }))];
|
|
438
|
+
const outdir = path.join(ctx.root, '__vb_out__');
|
|
439
|
+
const chunkDir = cfg.js.chunkDir.replace(/\/+$/, '');
|
|
440
|
+
const common = {
|
|
441
|
+
absWorkingDir: ctx.root,
|
|
442
|
+
entryPoints,
|
|
443
|
+
bundle: true,
|
|
444
|
+
splitting: true,
|
|
445
|
+
format: 'esm',
|
|
446
|
+
platform: 'browser',
|
|
447
|
+
outdir,
|
|
448
|
+
outbase: ctx.root,
|
|
449
|
+
entryNames: group.hash ? `${chunkDir}/[name]-[hash]` : '[dir]/[name]',
|
|
450
|
+
chunkNames: `${chunkDir}/[name]-[hash]`,
|
|
451
|
+
assetNames: `${chunkDir}/[name]-[hash]`,
|
|
452
|
+
write: false,
|
|
453
|
+
metafile: true,
|
|
454
|
+
charset: 'ascii',
|
|
455
|
+
supported: SUPPORTED,
|
|
456
|
+
logLevel: 'silent',
|
|
457
|
+
legalComments: cfg.js.legalComments,
|
|
458
|
+
define: cfg.js.define,
|
|
459
|
+
};
|
|
460
|
+
const scanState = newState('scan', group.vendorSplit);
|
|
461
|
+
let scan;
|
|
462
|
+
try {
|
|
463
|
+
scan = await esbuild.build({ ...common, target: 'esnext', plugins: [modulePlugin(ctx, group, scanState)] });
|
|
464
|
+
} catch (err) {
|
|
465
|
+
throw new BuildError('No se pudieron empaquetar los módulos JavaScript:', esbuildErrorText(err, ctx.root).split('\n'));
|
|
466
|
+
}
|
|
467
|
+
const inputs = Object.keys(scan.metafile.inputs)
|
|
468
|
+
.map((p) => relFrom(ctx.root, path.resolve(ctx.root, p)))
|
|
469
|
+
.filter((rel) => rel && ctx.files.has(rel));
|
|
470
|
+
const dynamicWarnings = scan.warnings.filter((w) => w.id === 'unsupported-dynamic-import');
|
|
471
|
+
let reason = null;
|
|
472
|
+
if (!cfg.js.bundle) reason = 'js.bundle está desactivado';
|
|
473
|
+
else if (group.forcePerFile) reason = group.forcePerFile;
|
|
474
|
+
else if (dynamicWarnings.length) reason = `hay import() con rutas que no se pueden seguir (${formatMessage(dynamicWarnings[0], ctx.root)})`;
|
|
475
|
+
else if (scanState.unsafe.length) reason = scanState.unsafe[0];
|
|
476
|
+
if (reason) return { mode: 'per-file', reason, inputs, consumed: new Set() };
|
|
477
|
+
for (const w of scan.warnings) ctx.warn(`JS: ${formatMessage(w, ctx.root)}`);
|
|
478
|
+
|
|
479
|
+
const target = cfg.js.target === 'auto' ? await targetForLevel(Math.max(2015, ...scanState.levels)) : cfg.js.target;
|
|
480
|
+
const emitState = newState('emit', group.vendorSplit);
|
|
481
|
+
emitState.analysis = scanState.analysis;
|
|
482
|
+
let vendor = null;
|
|
483
|
+
if (scanState.vendor?.size) {
|
|
484
|
+
try {
|
|
485
|
+
vendor = await buildVendor(ctx, group, common, scanState.vendor, target);
|
|
486
|
+
emitState.vendorOut = vendor.outByAbs;
|
|
487
|
+
} catch (err) {
|
|
488
|
+
ctx.warn(
|
|
489
|
+
`no se pudieron separar las dependencias en archivos vendor (${esbuildErrorText(err, ctx.root).split('\n')[0]}); van dentro del paquete y se ofuscarán con él`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
let res;
|
|
494
|
+
try {
|
|
495
|
+
res = await esbuild.build({
|
|
496
|
+
...common,
|
|
497
|
+
target,
|
|
498
|
+
minify: cfg.js.minify,
|
|
499
|
+
keepNames: cfg.js.keepNames,
|
|
500
|
+
drop: cfg.js.drop,
|
|
501
|
+
sourcemap: esbuildSourcemap(cfg),
|
|
502
|
+
plugins: [modulePlugin(ctx, group, emitState)],
|
|
503
|
+
});
|
|
504
|
+
} catch (err) {
|
|
505
|
+
throw new BuildError('No se pudieron empaquetar los módulos JavaScript:', esbuildErrorText(err, ctx.root).split('\n'));
|
|
506
|
+
}
|
|
507
|
+
const maps = new Map();
|
|
508
|
+
const files = [];
|
|
509
|
+
for (const f of res.outputFiles) {
|
|
510
|
+
const outRel = toPosix(path.relative(outdir, f.path));
|
|
511
|
+
if (outRel.endsWith('.map')) maps.set(outRel.slice(0, -4), f.text);
|
|
512
|
+
else files.push({ outRel, text: f.text, key: toPosix(path.relative(ctx.root, f.path)) });
|
|
513
|
+
}
|
|
514
|
+
const outputs = [];
|
|
515
|
+
for (const f of files) {
|
|
516
|
+
const meta = res.metafile.outputs[f.key] ?? {};
|
|
517
|
+
const srcInputs = Object.keys(meta.inputs ?? {})
|
|
518
|
+
.map((p) => relFrom(ctx.root, path.resolve(ctx.root, p)))
|
|
519
|
+
.filter((rel) => rel && ctx.files.has(rel));
|
|
520
|
+
// Los módulos inline se reconocen por su entrada ("vb-inline:..."), no por la ruta: esbuild puede moverlos.
|
|
521
|
+
const ep = meta.entryPoint ?? '';
|
|
522
|
+
const virtualId = [ep, ep.replace(/^vb-inline:/, '')].find((k) => group.virtual.has(k)) ?? null;
|
|
523
|
+
const entry = ep && !virtualId ? relFrom(ctx.root, path.resolve(ctx.root, ep)) : null;
|
|
524
|
+
let code = f.text.replace(/\n\/\/# sourceMappingURL=.*\n?$/, '\n');
|
|
525
|
+
let map = maps.get(f.outRel) ?? null;
|
|
526
|
+
if (virtualId) {
|
|
527
|
+
// El código irá dentro del HTML: sus rutas relativas deben partir de la página, no de donde lo dejó esbuild.
|
|
528
|
+
const v = group.virtual.get(virtualId);
|
|
529
|
+
const pageFile = `${v.baseDir ? `${v.baseDir}/` : ''}__page.html`;
|
|
530
|
+
({ code } = finalizeMarkers(code, null, pageFile));
|
|
531
|
+
code = rebaseImports(code, f.outRel, pageFile);
|
|
532
|
+
map = null;
|
|
533
|
+
} else ({ code, map } = finalizeMarkers(code, map, f.outRel));
|
|
534
|
+
outputs.push({ outRel: f.outRel, code, map, entry, virtualId, inputs: srcInputs, isChunk: !ep, before: inputBytes(res.metafile, meta) });
|
|
535
|
+
}
|
|
536
|
+
if (vendor) {
|
|
537
|
+
outputs.push(...vendor.outputs);
|
|
538
|
+
for (const rel of vendor.consumed) emitState.consumed.add(rel);
|
|
539
|
+
}
|
|
540
|
+
return { mode: 'bundle', outputs, consumed: emitState.consumed, inputs, target, vendor: vendor ? [...scanState.vendor.values()] : [] };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Worker o service worker de tipo módulo: se empaqueta solo, sin compartir chunks, en su ruta original. */
|
|
544
|
+
export async function buildModuleWorker(ctx, rel) {
|
|
545
|
+
const group = { importMap: null, virtual: new Map(), entries: new Set([rel]) };
|
|
546
|
+
const state = newState('scan');
|
|
547
|
+
const outfile = path.join(ctx.root, '__vb_out__', jsOutPath(rel));
|
|
548
|
+
const run = (st, extra) =>
|
|
549
|
+
esbuild.build({
|
|
550
|
+
absWorkingDir: ctx.root,
|
|
551
|
+
entryPoints: [ctx.abs(rel)],
|
|
552
|
+
bundle: true,
|
|
553
|
+
format: 'esm',
|
|
554
|
+
platform: 'browser',
|
|
555
|
+
outfile,
|
|
556
|
+
write: false,
|
|
557
|
+
metafile: true,
|
|
558
|
+
charset: 'ascii',
|
|
559
|
+
supported: SUPPORTED,
|
|
560
|
+
logLevel: 'silent',
|
|
561
|
+
legalComments: ctx.cfg.js.legalComments,
|
|
562
|
+
define: ctx.cfg.js.define,
|
|
563
|
+
plugins: [modulePlugin(ctx, group, st)],
|
|
564
|
+
...extra,
|
|
565
|
+
});
|
|
566
|
+
try {
|
|
567
|
+
await run(state, { target: 'esnext' });
|
|
568
|
+
const emit = newState('emit');
|
|
569
|
+
emit.analysis = state.analysis;
|
|
570
|
+
const js = ctx.cfg.js;
|
|
571
|
+
const target = js.target === 'auto' ? await targetForLevel(Math.max(2015, ...state.levels)) : js.target;
|
|
572
|
+
const res = await run(emit, { target, minify: js.minify, keepNames: js.keepNames, drop: js.drop, sourcemap: esbuildSourcemap(ctx.cfg) });
|
|
573
|
+
let code = '';
|
|
574
|
+
let map = null;
|
|
575
|
+
for (const f of res.outputFiles) {
|
|
576
|
+
if (f.path.endsWith('.map')) map = f.text;
|
|
577
|
+
else code = f.text.replace(/\n\/\/# sourceMappingURL=.*\n?$/, '\n');
|
|
578
|
+
}
|
|
579
|
+
({ code, map } = finalizeMarkers(code, map, jsOutPath(rel)));
|
|
580
|
+
return { code, map, consumed: emit.consumed };
|
|
581
|
+
} catch (err) {
|
|
582
|
+
return { error: esbuildErrorText(err, ctx.root) };
|
|
583
|
+
}
|
|
584
|
+
}
|
package/src/jsimports.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Rutas importadas por un JS ya generado (para verificar que existen en la salida).
|
|
2
|
+
import * as acorn from 'acorn';
|
|
3
|
+
import * as walk from 'acorn-walk';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reescribe los imports relativos de un archivo TypeScript ya transpilado para que funcionen en el navegador:
|
|
7
|
+
* "./x.ts" → "./x.js" (tras compilar existe con extensión .js) y "./x" → "./x.js" o "./x/index.js"
|
|
8
|
+
* según `complete(spec)`, que devuelve la ruta completa o null. Si el código no se puede leer, se devuelve igual.
|
|
9
|
+
*/
|
|
10
|
+
export function rewriteTsImports(code, complete = () => null) {
|
|
11
|
+
let ast;
|
|
12
|
+
try {
|
|
13
|
+
ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module', allowHashBang: true });
|
|
14
|
+
} catch {
|
|
15
|
+
return code;
|
|
16
|
+
}
|
|
17
|
+
const edits = [];
|
|
18
|
+
const consider = (src) => {
|
|
19
|
+
if (src?.type !== 'Literal' || typeof src.value !== 'string' || !/^\.{1,2}\//.test(src.value)) return;
|
|
20
|
+
let spec = src.value;
|
|
21
|
+
if (/\.(m|c)?ts$/i.test(spec)) spec = spec.replace(/\.(m|c)?ts$/i, (_, p) => `.${(p ?? '').toLowerCase()}js`);
|
|
22
|
+
else if (!/\.[a-z0-9]+$/i.test(spec.split('/').pop())) spec = complete(spec) ?? spec;
|
|
23
|
+
if (spec !== src.value) edits.push({ start: src.start, end: src.end, text: JSON.stringify(spec) });
|
|
24
|
+
};
|
|
25
|
+
const isImportMetaUrl = (m) =>
|
|
26
|
+
m?.type === 'MemberExpression' && m.object.type === 'MetaProperty' && m.object.meta.name === 'import' && m.property.name === 'url';
|
|
27
|
+
walk.full(ast, (n) => {
|
|
28
|
+
if (n.type === 'ImportDeclaration' || n.type === 'ExportAllDeclaration' || n.type === 'ExportNamedDeclaration') consider(n.source);
|
|
29
|
+
else if (n.type === 'ImportExpression') consider(n.source);
|
|
30
|
+
// new URL('./worker.ts', import.meta.url): workers y recursos cargados por ruta.
|
|
31
|
+
else if (n.type === 'NewExpression' && n.callee.type === 'Identifier' && n.callee.name === 'URL' && isImportMetaUrl(n.arguments[1]))
|
|
32
|
+
consider(n.arguments[0]);
|
|
33
|
+
});
|
|
34
|
+
let out = code;
|
|
35
|
+
for (const e of edits.sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Devuelve {imports} o {error} si el código no es JavaScript válido. */
|
|
40
|
+
export function jsImports(code, isModule) {
|
|
41
|
+
let ast;
|
|
42
|
+
try {
|
|
43
|
+
ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: isModule ? 'module' : 'script', allowHashBang: true });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return { error: err.message };
|
|
46
|
+
}
|
|
47
|
+
const imports = [];
|
|
48
|
+
walk.full(ast, (n) => {
|
|
49
|
+
if ((n.type === 'ImportDeclaration' || n.type === 'ExportAllDeclaration' || n.type === 'ExportNamedDeclaration') && n.source)
|
|
50
|
+
imports.push(n.source.value);
|
|
51
|
+
if (n.type === 'ImportExpression' && n.source.type === 'Literal' && typeof n.source.value === 'string') imports.push(n.source.value);
|
|
52
|
+
});
|
|
53
|
+
return { imports };
|
|
54
|
+
}
|