vanilla-builder 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/files.js CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import picomatch from 'picomatch';
5
5
  import { CONFIG_FILES } from './config.js';
6
6
  import { relFrom, toPosix, extOf } from './util.js';
7
+ import { L } from './i18n.js';
7
8
 
8
9
  /** Archivos de desarrollo que nunca deberían publicarse. Se desactiva con defaultIgnore:false. */
9
10
  export const DEFAULT_IGNORE = [
@@ -33,9 +34,16 @@ export const DEFAULT_IGNORE = [
33
34
  '**/eslint.config.*',
34
35
  '**/prettier.config.*',
35
36
  'tsconfig.json',
37
+ 'tsconfig.*.json',
36
38
  'jsconfig.json',
39
+ // Declaraciones de tipos: no contienen código ejecutable.
40
+ '**/*.d.ts',
41
+ '**/*.d.mts',
42
+ '**/*.d.cts',
37
43
  '**/*.test.js',
38
44
  '**/*.spec.js',
45
+ '**/*.test.ts',
46
+ '**/*.spec.ts',
39
47
  '**/__tests__/**',
40
48
  '**/*.map',
41
49
  ];
@@ -78,7 +86,7 @@ export function scanProject(cfg) {
78
86
  try {
79
87
  entries = fs.readdirSync(dir, { withFileTypes: true });
80
88
  } catch (err) {
81
- ignored.push({ rel: relFrom(root, dir) || '.', reason: `no se pudo leer (${err.code})` });
89
+ ignored.push({ rel: relFrom(root, dir) || '.', reason: L(`no se pudo leer (${err.code})`, `could not be read (${err.code})`) });
82
90
  return;
83
91
  }
84
92
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
@@ -93,9 +101,10 @@ export function scanProject(cfg) {
93
101
  const st = fs.statSync(abs);
94
102
  isFile = st.isFile();
95
103
  isDir = false;
96
- if (st.isDirectory()) ignored.push({ rel, reason: 'enlace simbólico a carpeta (no se sigue)' });
104
+ if (st.isDirectory())
105
+ ignored.push({ rel, reason: L('enlace simbólico a carpeta (no se sigue)', 'symbolic link to a folder (not followed)') });
97
106
  } catch {
98
- ignored.push({ rel, reason: 'enlace simbólico roto' });
107
+ ignored.push({ rel, reason: L('enlace simbólico roto', 'broken symbolic link') });
99
108
  continue;
100
109
  }
101
110
  }
@@ -106,7 +115,10 @@ export function scanProject(cfg) {
106
115
  const byUser = isUser(rel) || isUser(probe);
107
116
  const wanted = cfg.include.some((p) => normalizePattern(p).startsWith(`${rel}/`) || p.startsWith('**'));
108
117
  if ((byUser || isDefault(probe)) && !wanted) {
109
- ignored.push({ rel: `${rel}/`, reason: byUser ? 'ignore (configuración)' : 'excluido por defecto' });
118
+ ignored.push({
119
+ rel: `${rel}/`,
120
+ reason: byUser ? L('ignore (configuración)', 'ignore (configuration)') : L('excluido por defecto', 'excluded by default'),
121
+ });
110
122
  continue;
111
123
  }
112
124
  walk(abs);
@@ -116,9 +128,9 @@ export function scanProject(cfg) {
116
128
  const forced = isIncluded(rel);
117
129
  let reason = null;
118
130
  if (!forced) {
119
- if (isSecret(rel)) reason = 'posible secreto (excluido por seguridad)';
120
- else if (isUser(rel)) reason = 'ignore (configuración)';
121
- else if (isDefault(rel)) reason = 'excluido por defecto';
131
+ if (isSecret(rel)) reason = L('posible secreto (excluido por seguridad)', 'possible secret (excluded for safety)');
132
+ else if (isUser(rel)) reason = L('ignore (configuración)', 'ignore (configuration)');
133
+ else if (isDefault(rel)) reason = L('excluido por defecto', 'excluded by default');
122
134
  }
123
135
  if (reason) {
124
136
  ignored.push({ rel, reason });
package/src/html.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // HTML: extrae referencias con su posición exacta, aplica ediciones puntuales y minifica con verificación.
2
2
  import { parse } from 'parse5';
3
+ import { L } from './i18n.js';
3
4
 
4
5
  const JS_TYPES = new Set([
5
6
  '',
@@ -215,7 +216,7 @@ export function applyEdits(text, edits) {
215
216
  let out = text;
216
217
  let limit = Infinity;
217
218
  for (const e of sorted) {
218
- if (e.end > limit) throw new Error(`Ediciones superpuestas en la posición ${e.start}`);
219
+ if (e.end > limit) throw new Error(L(`Ediciones superpuestas en la posición ${e.start}`, `Overlapping edits at position ${e.start}`));
219
220
  out = out.slice(0, e.start) + e.text + out.slice(e.end);
220
221
  limit = e.start;
221
222
  }
@@ -324,7 +325,7 @@ export async function minifyHtml(text, { removeComments, collapseWhitespace }) {
324
325
  try {
325
326
  out = applyEdits(text, minifyEdits(text, { removeComments, collapseWhitespace }));
326
327
  } catch (err) {
327
- return { code: text, note: `no se pudo minificar el HTML (${err.message})` };
328
+ return { code: text, note: L(`no se pudo minificar el HTML (${err.message})`, `could not minify the HTML (${err.message})`) };
328
329
  }
329
330
  const before = htmlSignature(text);
330
331
  const after = htmlSignature(out);
@@ -332,7 +333,13 @@ export async function minifyHtml(text, { removeComments, collapseWhitespace }) {
332
333
  let i = 0;
333
334
  while (i < before.length && before[i] === after[i]) i++;
334
335
  const near = before.slice(Math.max(0, i - 40), i + 40).replace(/\s+/g, ' ');
335
- return { code: text, note: `la minificación cambiaba la estructura cerca de «${near}»; se deja sin minificar` };
336
+ return {
337
+ code: text,
338
+ note: L(
339
+ `la minificación cambiaba la estructura cerca de «${near}»; se deja sin minificar`,
340
+ `minification changed the structure near «${near}»; left unminified`,
341
+ ),
342
+ };
336
343
  }
337
344
  return { code: out };
338
345
  }
package/src/i18n.js ADDED
@@ -0,0 +1,33 @@
1
+ // Idioma de los mensajes: inglés o español.
2
+ // Prioridad: --lang / setLang() > BUILDER_LANG > LC_ALL / LC_MESSAGES / LANG > idioma del sistema > inglés.
3
+ // Los campos de --json, la configuración y los nombres de fase no se traducen nunca.
4
+
5
+ function detect() {
6
+ const env = process.env;
7
+ const explicit = env.BUILDER_LANG;
8
+ if (explicit) return /^es/i.test(explicit) ? 'es' : 'en';
9
+ const posix = env.LC_ALL || env.LC_MESSAGES || env.LANG;
10
+ // "C" y "POSIX" no dicen nada del idioma de la persona: se mira el del sistema.
11
+ if (posix && !/^(C|POSIX)(\.|$)/i.test(posix)) return /^es/i.test(posix) ? 'es' : 'en';
12
+ try {
13
+ return /^es\b/i.test(Intl.DateTimeFormat().resolvedOptions().locale) ? 'es' : 'en';
14
+ } catch {
15
+ return 'en';
16
+ }
17
+ }
18
+
19
+ let lang = detect();
20
+
21
+ /** Cambia el idioma ('en' o 'es'). */
22
+ export function setLang(value) {
23
+ lang = /^es/i.test(String(value)) ? 'es' : 'en';
24
+ }
25
+
26
+ export function getLang() {
27
+ return lang;
28
+ }
29
+
30
+ /** Devuelve el texto en el idioma activo. Ambos textos se escriben juntos en el código. */
31
+ export function L(es, en) {
32
+ return lang === 'es' ? es : en;
33
+ }
package/src/images.js ADDED
@@ -0,0 +1,61 @@
1
+ // Optimización de imágenes con sharp (dependencia opcional). Mismo formato y mismo nombre: no cambia ninguna referencia.
2
+ // - PNG: recompresión sin pérdida, comprobada píxel a píxel.
3
+ // - JPEG y WebP: se recodifican con la calidad indicada (con pérdida, visualmente equivalente).
4
+ // Se conservan los metadatos (orientación EXIF, perfil de color). Si el resultado no es más pequeño, se deja el original.
5
+ import { importOptional } from './optional.js';
6
+ import { L } from './i18n.js';
7
+
8
+ export const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp']);
9
+
10
+ let sharpMod = null;
11
+
12
+ export async function ensureSharp(root) {
13
+ if (!sharpMod) {
14
+ const mod = await importOptional('sharp', root, L('para optimizar imágenes (--images)', 'to optimize images (--images)'));
15
+ sharpMod = mod.default ?? mod;
16
+ }
17
+ return sharpMod;
18
+ }
19
+
20
+ /** Opciones normalizadas de "images": false, true o { quality }. */
21
+ export function imageOptions(setting) {
22
+ if (!setting) return null;
23
+ const quality = typeof setting === 'object' && Number.isInteger(setting.quality) ? setting.quality : 80;
24
+ return { quality };
25
+ }
26
+
27
+ async function samePixels(sharp, a, b) {
28
+ const [ra, rb] = await Promise.all([
29
+ sharp(a).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
30
+ sharp(b).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
31
+ ]);
32
+ return ra.info.width === rb.info.width && ra.info.height === rb.info.height && ra.data.equals(rb.data);
33
+ }
34
+
35
+ /**
36
+ * @returns {Promise<{data: Buffer} | {skip: string}>} la imagen optimizada, o el motivo para dejar la original.
37
+ */
38
+ export async function optimizeImage(sharp, buf, ext, { quality }) {
39
+ let meta;
40
+ try {
41
+ meta = await sharp(buf).metadata();
42
+ } catch (err) {
43
+ return { skip: L(`no se pudo leer la imagen (${err.message})`, `could not read the image (${err.message})`) };
44
+ }
45
+ if ((meta.pages ?? 1) > 1) return { skip: L('imagen animada', 'animated image') };
46
+ const base = () => sharp(buf).keepMetadata();
47
+ let out;
48
+ try {
49
+ if (ext === '.png') out = await base().png({ compressionLevel: 9, effort: 10, palette: false, adaptiveFiltering: true }).toBuffer();
50
+ else if (ext === '.webp') out = await base().webp({ quality, effort: 6 }).toBuffer();
51
+ else out = await base().jpeg({ quality, mozjpeg: true }).toBuffer();
52
+ } catch (err) {
53
+ return { skip: L(`sharp falló (${err.message})`, `sharp failed (${err.message})`) };
54
+ }
55
+ if (out.length >= buf.length) return { skip: L('ya estaba optimizada', 'already optimized') };
56
+ const after = await sharp(out).metadata();
57
+ if (after.width !== meta.width || after.height !== meta.height || after.format !== meta.format)
58
+ return { skip: L('el resultado no conserva tamaño o formato', 'the result does not keep size or format') };
59
+ if (ext === '.png' && !(await samePixels(sharp, buf, out))) return { skip: L('los píxeles no coinciden', 'pixels do not match') };
60
+ return { data: out };
61
+ }
package/src/js.js CHANGED
@@ -4,13 +4,13 @@ import fs from 'node:fs';
4
4
  import * as esbuild from 'esbuild';
5
5
  import * as acorn from 'acorn';
6
6
  import * as walk from 'acorn-walk';
7
- import { analyzeJs, compareGlobals, moduleShape } from './jsinfo.js';
8
- import { obfuscateSafe } from './obfuscate.js';
7
+ import { analyzeJs, compareGlobals, isolateTempGlobals } from './jsinfo.js';
9
8
  import { importOptional } from './optional.js';
10
9
  import { replaceWithMap } from './sourcemap.js';
11
- import { relFrom, toPosix, BuildError, rootPrefix, hasOwn, shortHash } from './util.js';
10
+ import { relFrom, toPosix, BuildError, rootPrefix, hasOwn, shortHash, jsOutPath } from './util.js';
12
11
  import { classifyUrl } from './urls.js';
13
12
  import { formatMessage, esbuildErrorText } from './css.js';
13
+ import { L } from './i18n.js';
14
14
 
15
15
  /** Marcador de "raíz de salida" que se sustituye por la ruta relativa real de cada archivo generado. */
16
16
  export const MARK = '__VB_ROOT__';
@@ -56,50 +56,75 @@ export async function processClassic(ctx, { code, id, info, obfuscate, sourcemap
56
56
  const notes = [];
57
57
  let out = code;
58
58
  let map = null;
59
+ let allowPrefix = null;
59
60
  if (js.minify) {
60
61
  const auto = await targetForLevel(info.level);
61
- const attempts = [{ target: js.target === 'auto' ? auto : js.target, keepNames: js.keepNames, label: 'completa' }];
62
- if (js.target !== 'auto' || js.keepNames) attempts.push({ target: auto, keepNames: false, label: 'sin transpilar' });
63
- attempts.push({ target: auto, keepNames: false, whitespace: true, label: 'solo espacios' });
62
+ const attempts = [{ target: js.target === 'auto' ? auto : js.target, keepNames: js.keepNames, label: L('completa', 'full') }];
63
+ if (js.target !== 'auto' || js.keepNames) attempts.push({ target: auto, keepNames: false, label: L('sin transpilar', 'without transpiling') });
64
+ attempts.push({ target: auto, keepNames: false, whitespace: true, label: L('solo espacios', 'whitespace only') });
64
65
  let done = false;
66
+ // Mismo prefijo que usa el ofuscador para sus nombres: las comprobaciones posteriores ya lo aceptan.
67
+ const tempPrefix = `_${shortHash(id, 6)}`;
68
+ const transform = (input, a, extra = {}) =>
69
+ esbuild.transform(input, {
70
+ loader: 'js',
71
+ target: a.target,
72
+ charset: 'ascii',
73
+ supported: SUPPORTED,
74
+ legalComments: js.legalComments,
75
+ drop: js.drop,
76
+ define: js.define,
77
+ keepNames: a.keepNames,
78
+ sourcemap: sourcemap ? 'external' : false,
79
+ sourcefile: id,
80
+ ...(a.whitespace ? { minifyWhitespace: true } : { minify: true }),
81
+ logLevel: 'silent',
82
+ ...extra,
83
+ });
65
84
  for (const a of attempts) {
66
85
  try {
67
- const r = await esbuild.transform(code, {
68
- loader: 'js',
69
- target: a.target,
70
- charset: 'ascii',
71
- supported: SUPPORTED,
72
- legalComments: js.legalComments,
73
- drop: js.drop,
74
- define: js.define,
75
- keepNames: a.keepNames,
76
- sourcemap: sourcemap ? 'external' : false,
77
- sourcefile: id,
78
- ...(a.whitespace ? { minifyWhitespace: true } : { minify: true }),
79
- logLevel: 'silent',
80
- });
81
- const cmp = compareGlobals(info.top, r.code);
86
+ const r = await transform(code, a);
87
+ let candidate = r.code;
88
+ let cmp = compareGlobals(info.top, candidate);
89
+ let isolated = false;
90
+ if (!cmp.ok && !a.whitespace && a.target !== auto) {
91
+ // Al adaptar la sintaxis, esbuild declara temporales globales (var _a). Se adapta sin minificar
92
+ // (nombres reconocibles), se aíslan con un prefijo propio del archivo y después se minifica:
93
+ // la minificación no renombra globales de un script clásico ni crea temporales nuevas.
94
+ const lowered = await transform(code, a, { minify: false, minifyWhitespace: false, sourcemap: false });
95
+ const iso = isolateTempGlobals(lowered.code, info.top, tempPrefix);
96
+ if (iso) {
97
+ const r2 = await transform(iso, a, { sourcemap: false });
98
+ const cmp2 = compareGlobals(info.top, r2.code, tempPrefix);
99
+ if (cmp2.ok) {
100
+ candidate = r2.code;
101
+ cmp = cmp2;
102
+ isolated = true;
103
+ }
104
+ }
105
+ }
82
106
  if (cmp.ok) {
83
- out = r.code;
84
- map = r.map || null;
107
+ out = candidate;
108
+ map = isolated ? null : r.map || null;
109
+ if (isolated) allowPrefix = tempPrefix;
85
110
  done = true;
86
111
  break;
87
112
  }
88
- notes.push(`minificación ${a.label} descartada: ${cmp.reason}`);
113
+ notes.push(L(`minificación ${a.label} descartada: ${cmp.reason}`, `${a.label} minification discarded: ${cmp.reason}`));
89
114
  } catch (err) {
90
- notes.push(`minificación ${a.label} falló: ${firstLine(err)}`);
115
+ notes.push(L(`minificación ${a.label} falló: ${firstLine(err)}`, `${a.label} minification failed: ${firstLine(err)}`));
91
116
  }
92
117
  }
93
- if (!done) notes.push('se deja sin minificar para no romperlo');
118
+ if (!done) notes.push(L('se deja sin minificar para no romperlo', 'left unminified so it does not break'));
94
119
  }
95
120
  if (obfuscate) {
96
- const o = obfuscateSafe(out, { isModule: false, options: ctx.obfuscatorOptions, id, globals: info.top });
121
+ const o = await ctx.heavy.obfuscate(out, { isModule: false, options: ctx.obfuscatorOptions, id, globals: info.top });
97
122
  if (o.code) {
98
123
  out = o.code;
99
124
  map = null;
100
- } else notes.push(`no se ofuscó: ${o.reason}`);
125
+ } else notes.push(L(`no se ofuscó: ${o.reason}`, `not obfuscated: ${o.reason}`));
101
126
  } else if (out !== code) {
102
- const t = await terserPass(ctx, out, { isModule: false, level: info.level, globals: info.top });
127
+ const t = await terserPass(ctx, out, { isModule: false, level: info.level, globals: info.top, allowPrefix });
103
128
  if (t !== out) {
104
129
  out = t;
105
130
  map = null;
@@ -134,15 +159,15 @@ export async function processModuleStandalone(ctx, { code, id, info, obfuscate,
134
159
  out = r.code;
135
160
  map = r.map || null;
136
161
  } catch (err) {
137
- notes.push(`no se pudo minificar: ${firstLine(err)}`);
162
+ notes.push(L(`no se pudo minificar: ${firstLine(err)}`, `could not minify: ${firstLine(err)}`));
138
163
  }
139
164
  }
140
165
  if (obfuscate) {
141
- const o = obfuscateSafe(out, { isModule: true, options: ctx.obfuscatorOptions, id });
166
+ const o = await ctx.heavy.obfuscate(out, { isModule: true, options: ctx.obfuscatorOptions, id });
142
167
  if (o.code) {
143
168
  out = o.code;
144
169
  map = null;
145
- } else notes.push(`no se ofuscó: ${o.reason}`);
170
+ } else notes.push(L(`no se ofuscó: ${o.reason}`, `not obfuscated: ${o.reason}`));
146
171
  } else if (out !== code) {
147
172
  const t = await terserPass(ctx, out, { isModule: true, level: info.level });
148
173
  if (t !== out) {
@@ -154,9 +179,9 @@ export async function processModuleStandalone(ctx, { code, id, info, obfuscate,
154
179
  }
155
180
 
156
181
  /** Ofusca un módulo ya generado (paquete, chunk o worker). */
157
- export function obfuscateModule(ctx, code, id) {
158
- const o = obfuscateSafe(code, { isModule: true, options: ctx.obfuscatorOptions, id });
159
- return o.code ? { code: o.code } : { code, note: `no se ofuscó: ${o.reason}` };
182
+ export async function obfuscateModule(ctx, code, id) {
183
+ const o = await ctx.heavy.obfuscate(code, { isModule: true, options: ctx.obfuscatorOptions, id });
184
+ return o.code ? { code: o.code } : { code, note: L(`no se ofuscó: ${o.reason}`, `not obfuscated: ${o.reason}`) };
160
185
  }
161
186
 
162
187
  /** Aplica un import map (solo "imports"; con "scopes" se usa el modo sin empaquetar). */
@@ -196,6 +221,14 @@ export function mapSpecifier(ctx, importMap, spec, importerDir) {
196
221
 
197
222
  const EXTERNAL_SPEC = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
198
223
 
224
+ function isFile(abs) {
225
+ try {
226
+ return fs.statSync(abs).isFile();
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
231
+
199
232
  function modulePlugin(ctx, group, state) {
200
233
  const isDepPath = (abs) => {
201
234
  if (!abs) return false;
@@ -212,8 +245,10 @@ function modulePlugin(ctx, group, state) {
212
245
  if (!info.ok) return code;
213
246
  state.levels.push(info.level ?? 9999);
214
247
  if (!local) return code;
215
- for (const u of info.unsafeUrls) state.unsafe.push(`new URL(variable, import.meta.url) en ${id}: ${u}`);
216
- for (const u of info.unsafeImports) state.unsafe.push(`import() con una ruta calculada en ${id}: ${u}`);
248
+ for (const u of info.unsafeUrls)
249
+ state.unsafe.push(L(`new URL(variable, import.meta.url) en ${id}: ${u}`, `new URL(variable, import.meta.url) in ${id}: ${u}`));
250
+ for (const u of info.unsafeImports)
251
+ state.unsafe.push(L(`import() con una ruta calculada en ${id}: ${u}`, `import() with a computed path in ${id}: ${u}`));
217
252
  if (state.mode === 'scan') ctx.noteJsInfo(id, info, baseDir, { module: true });
218
253
  if (!info.urls.length) return code;
219
254
  let out = '';
@@ -245,6 +280,11 @@ function modulePlugin(ctx, group, state) {
245
280
  if (!state.vendor || a.pluginData?.vbInner || r?.external || r?.errors) return r;
246
281
  if (a.namespace !== 'vb-inline' && isDepPath(a.importer)) return r;
247
282
  let abs = r?.path;
283
+ // Ruta relativa a un archivo que existe: se resuelve aquí, sin otro viaje de ida y vuelta a esbuild.
284
+ if (!abs && /^\.\.?\//.test(a.path) && a.namespace !== 'vb-inline' && a.resolveDir) {
285
+ const direct = path.resolve(a.resolveDir, a.path);
286
+ if (path.extname(direct) && isFile(direct)) abs = direct;
287
+ }
248
288
  if (!abs) {
249
289
  const res = await b.resolve(a.path, {
250
290
  kind: a.kind,
@@ -275,24 +315,39 @@ function modulePlugin(ctx, group, state) {
275
315
  ctx.reportBroken(importer, spec, r);
276
316
  return { path: spec, external: true };
277
317
  }
278
- ctx.requireStandalone(r.rel, `importado como CSS desde ${importer}`);
318
+ ctx.requireStandalone(r.rel, L(`importado como CSS desde ${importer}`, `imported as CSS from ${importer}`));
279
319
  return { path: `${MARK}/${r.rel}`, external: true };
280
320
  }
281
321
  const mapped = mapSpecifier(ctx, group.importMap, spec, baseDir);
282
322
  if (mapped?.external) return { path: spec, external: true };
283
323
  if (mapped?.rel) return { path: ctx.abs(mapped.rel) };
284
324
  if (mapped?.abs) return { path: mapped.abs };
285
- if (mapped?.broken) return { errors: [{ text: `El import map de la página apunta "${spec}" a "${mapped.target}", que no existe.` }] };
325
+ if (mapped?.broken)
326
+ return {
327
+ errors: [
328
+ {
329
+ text: L(
330
+ `El import map de la página apunta "${spec}" a "${mapped.target}", que no existe.`,
331
+ `The page import map points "${spec}" to "${mapped.target}", which does not exist.`,
332
+ ),
333
+ },
334
+ ],
335
+ };
286
336
  if (spec.startsWith('/')) {
287
337
  const r = ctx.resolve(spec, baseDir);
288
338
  if (r.status === 'ok') return { path: ctx.abs(r.rel) };
289
- return { errors: [{ text: `No existe "${spec}" (importado desde ${importer}).` }] };
339
+ return {
340
+ errors: [{ text: L(`No existe "${spec}" (importado desde ${importer}).`, `"${spec}" does not exist (imported from ${importer}).`) }],
341
+ };
290
342
  }
291
343
  return undefined;
292
344
  };
293
- b.onLoad({ filter: /\.(?:m?js|cjs)$/i }, (a) => {
345
+ b.onLoad({ filter: /\.(?:m?js|cjs|m?ts|cts)$/i }, (a) => {
294
346
  const rel = relFrom(ctx.root, a.path);
295
347
  const local = rel != null && !/(^|\/)node_modules\//.test(rel) && ctx.files.has(rel);
348
+ // Los .ts propios llegan ya transpilados (readText); los de node_modules los transpila esbuild.
349
+ const isTs = /\.(?:m?ts|cts)$/i.test(a.path);
350
+ if (!local && isTs) return { contents: fs.readFileSync(a.path, 'utf8'), loader: 'ts' };
296
351
  const code = local ? ctx.readText(rel, { kind: 'js' }).text : fs.readFileSync(a.path, 'utf8');
297
352
  if (local) state.consumed.add(rel);
298
353
  return { contents: prepare(code, local ? rel : toPosix(a.path), local ? path.posix.dirname(rel) : '', local), loader: 'js' };
@@ -309,40 +364,21 @@ let terser = null;
309
364
 
310
365
  /** Carga terser (opcional) para la compresión máxima. */
311
366
  export async function ensureTerser(projectRoot) {
312
- terser = await importOptional('terser', projectRoot, 'para la compresión máxima (js.minifier: "terser")');
367
+ terser = await importOptional(
368
+ 'terser',
369
+ projectRoot,
370
+ L('para la compresión máxima (js.minifier: "terser")', 'for maximum compression (js.minifier: "terser")'),
371
+ );
313
372
  }
314
373
 
315
374
  /**
316
375
  * Segunda pasada con terser (varias pasadas de compresión). Solo se acepta si el resultado
317
376
  * es más pequeño y conserva globales (scripts) o exportaciones/importaciones (módulos).
318
377
  */
319
- export async function terserPass(ctx, code, { isModule, level, globals }) {
378
+ export async function terserPass(ctx, code, { isModule, level, globals, allowPrefix = null }) {
320
379
  const js = ctx.cfg.js;
321
380
  if (!terser || js.minifier !== 'terser' || !js.minify || ctx.cfg.sourcemap) return code;
322
- const ecma = Math.min(2020, Math.max(5, level ?? 2020));
323
- let out;
324
- try {
325
- const r = await terser.minify(code, {
326
- module: isModule,
327
- toplevel: isModule,
328
- ecma,
329
- compress: { passes: 3, ecma, module: isModule, toplevel: isModule },
330
- mangle: { toplevel: isModule },
331
- keep_fnames: js.keepNames,
332
- keep_classnames: js.keepNames,
333
- format: { comments: /^!|@license|@preserve/i, ascii_only: true, ecma },
334
- });
335
- out = r.code;
336
- } catch {
337
- return code;
338
- }
339
- if (!out || out.length >= code.trimEnd().length) return code;
340
- if (isModule) {
341
- const a = moduleShape(code);
342
- const b = moduleShape(out);
343
- if (!a || !b || a.exports !== b.exports || a.imports !== b.imports) return code;
344
- } else if (!globals || !compareGlobals(globals, out).ok) return code;
345
- return out;
381
+ return ctx.heavy.terser(code, { isModule, level, globals: isModule ? undefined : globals, allowPrefix, keepNames: js.keepNames });
346
382
  }
347
383
 
348
384
  /** Bytes de origen que aportaron código a una salida (incluye paquetes de node_modules). */
@@ -470,16 +506,23 @@ export async function buildModuleGroup(ctx, group) {
470
506
  try {
471
507
  scan = await esbuild.build({ ...common, target: 'esnext', plugins: [modulePlugin(ctx, group, scanState)] });
472
508
  } catch (err) {
473
- throw new BuildError('No se pudieron empaquetar los módulos JavaScript:', esbuildErrorText(err, ctx.root).split('\n'));
509
+ throw new BuildError(
510
+ L('No se pudieron empaquetar los módulos JavaScript:', 'Could not bundle the JavaScript modules:'),
511
+ esbuildErrorText(err, ctx.root).split('\n'),
512
+ );
474
513
  }
475
514
  const inputs = Object.keys(scan.metafile.inputs)
476
515
  .map((p) => relFrom(ctx.root, path.resolve(ctx.root, p)))
477
516
  .filter((rel) => rel && ctx.files.has(rel));
478
517
  const dynamicWarnings = scan.warnings.filter((w) => w.id === 'unsupported-dynamic-import');
479
518
  let reason = null;
480
- if (!cfg.js.bundle) reason = 'js.bundle está desactivado';
519
+ if (!cfg.js.bundle) reason = L('js.bundle está desactivado', 'js.bundle is disabled');
481
520
  else if (group.forcePerFile) reason = group.forcePerFile;
482
- else if (dynamicWarnings.length) reason = `hay import() con rutas que no se pueden seguir (${formatMessage(dynamicWarnings[0], ctx.root)})`;
521
+ else if (dynamicWarnings.length)
522
+ reason = L(
523
+ `hay import() con rutas que no se pueden seguir (${formatMessage(dynamicWarnings[0], ctx.root)})`,
524
+ `there are import() calls with paths that cannot be followed (${formatMessage(dynamicWarnings[0], ctx.root)})`,
525
+ );
483
526
  else if (scanState.unsafe.length) reason = scanState.unsafe[0];
484
527
  if (reason) return { mode: 'per-file', reason, inputs, consumed: new Set() };
485
528
  for (const w of scan.warnings) ctx.warn(`JS: ${formatMessage(w, ctx.root)}`);
@@ -494,7 +537,10 @@ export async function buildModuleGroup(ctx, group) {
494
537
  emitState.vendorOut = vendor.outByAbs;
495
538
  } catch (err) {
496
539
  ctx.warn(
497
- `no se pudieron separar las dependencias en archivos vendor (${esbuildErrorText(err, ctx.root).split('\n')[0]}); van dentro del paquete y se ofuscarán con él`,
540
+ L(
541
+ `no se pudieron separar las dependencias en archivos vendor (${esbuildErrorText(err, ctx.root).split('\n')[0]}); van dentro del paquete y se ofuscarán con él`,
542
+ `could not split dependencies into vendor files (${esbuildErrorText(err, ctx.root).split('\n')[0]}); they stay in the bundle and will be obfuscated with it`,
543
+ ),
498
544
  );
499
545
  }
500
546
  }
@@ -510,7 +556,10 @@ export async function buildModuleGroup(ctx, group) {
510
556
  plugins: [modulePlugin(ctx, group, emitState)],
511
557
  });
512
558
  } catch (err) {
513
- throw new BuildError('No se pudieron empaquetar los módulos JavaScript:', esbuildErrorText(err, ctx.root).split('\n'));
559
+ throw new BuildError(
560
+ L('No se pudieron empaquetar los módulos JavaScript:', 'Could not bundle the JavaScript modules:'),
561
+ esbuildErrorText(err, ctx.root).split('\n'),
562
+ );
514
563
  }
515
564
  const maps = new Map();
516
565
  const files = [];
@@ -552,7 +601,7 @@ export async function buildModuleGroup(ctx, group) {
552
601
  export async function buildModuleWorker(ctx, rel) {
553
602
  const group = { importMap: null, virtual: new Map(), entries: new Set([rel]) };
554
603
  const state = newState('scan');
555
- const outfile = path.join(ctx.root, '__vb_out__', rel);
604
+ const outfile = path.join(ctx.root, '__vb_out__', jsOutPath(rel));
556
605
  const run = (st, extra) =>
557
606
  esbuild.build({
558
607
  absWorkingDir: ctx.root,
@@ -584,7 +633,7 @@ export async function buildModuleWorker(ctx, rel) {
584
633
  if (f.path.endsWith('.map')) map = f.text;
585
634
  else code = f.text.replace(/\n\/\/# sourceMappingURL=.*\n?$/, '\n');
586
635
  }
587
- ({ code, map } = finalizeMarkers(code, map, rel));
636
+ ({ code, map } = finalizeMarkers(code, map, jsOutPath(rel)));
588
637
  return { code, map, consumed: emit.consumed };
589
638
  } catch (err) {
590
639
  return { error: esbuildErrorText(err, ctx.root) };
@@ -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
+ }