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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.es.md +162 -0
- package/README.md +163 -2
- package/bin/builder.js +367 -0
- package/package.json +79 -4
- package/src/builder.js +1202 -0
- package/src/config.js +190 -0
- package/src/css.js +166 -0
- package/src/deps.js +140 -0
- package/src/files.js +152 -0
- package/src/html.js +446 -0
- package/src/index.js +22 -0
- package/src/js.js +592 -0
- package/src/jsinfo.js +290 -0
- package/src/obfuscate.js +160 -0
- package/src/optional.js +38 -0
- package/src/output.js +100 -0
- package/src/report.js +79 -0
- package/src/server.js +124 -0
- package/src/sourcemap.js +77 -0
- package/src/urls.js +105 -0
- package/src/util.js +151 -0
- package/src/verify.js +148 -0
- package/src/watch.js +60 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Carga, combina y valida la configuración (builder.config.json / .mjs + opciones de la CLI).
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { BuildError } from './util.js';
|
|
6
|
+
|
|
7
|
+
export const CONFIG_FILES = ['builder.config.json', 'builder.config.mjs', 'builder.config.js'];
|
|
8
|
+
|
|
9
|
+
export const DEFAULTS = {
|
|
10
|
+
root: '.',
|
|
11
|
+
outDir: 'dist',
|
|
12
|
+
entries: 'auto',
|
|
13
|
+
include: [],
|
|
14
|
+
ignore: [],
|
|
15
|
+
defaultIgnore: true,
|
|
16
|
+
unreferenced: 'process',
|
|
17
|
+
base: '/',
|
|
18
|
+
hash: false,
|
|
19
|
+
sourcemap: false,
|
|
20
|
+
clean: true,
|
|
21
|
+
verify: true,
|
|
22
|
+
sri: 'auto',
|
|
23
|
+
html: { minify: true, removeComments: true, collapseWhitespace: 'auto' },
|
|
24
|
+
css: { minify: true, bundle: true, target: 'esnext' },
|
|
25
|
+
js: {
|
|
26
|
+
minify: true,
|
|
27
|
+
bundle: true,
|
|
28
|
+
target: 'auto',
|
|
29
|
+
mergeClassic: 'auto',
|
|
30
|
+
keepNames: false,
|
|
31
|
+
drop: [],
|
|
32
|
+
legalComments: 'eof',
|
|
33
|
+
define: {},
|
|
34
|
+
chunkDir: 'assets',
|
|
35
|
+
minifier: 'esbuild',
|
|
36
|
+
},
|
|
37
|
+
json: { minify: true },
|
|
38
|
+
obfuscate: false,
|
|
39
|
+
// Librerías de terceros: se detectan solas y no se ofuscan salvo que se pida.
|
|
40
|
+
dependencies: { detect: true, obfuscate: false, include: [], exclude: [] },
|
|
41
|
+
compress: false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const NESTED = ['html', 'css', 'js', 'json', 'dependencies'];
|
|
45
|
+
const ENUMS = {
|
|
46
|
+
unreferenced: ['process', 'copy', 'skip'],
|
|
47
|
+
sri: ['auto', true, false],
|
|
48
|
+
sourcemap: [false, true, 'hidden', 'inline'],
|
|
49
|
+
'js.mergeClassic': ['auto', true, false],
|
|
50
|
+
'js.legalComments': ['none', 'inline', 'eof'],
|
|
51
|
+
'html.collapseWhitespace': ['auto', true, false],
|
|
52
|
+
'js.minifier': ['esbuild', 'terser'],
|
|
53
|
+
compress: [false, true, 'gzip', 'brotli'],
|
|
54
|
+
};
|
|
55
|
+
const OBF_PRESETS = ['low', 'medium', 'high', 'max'];
|
|
56
|
+
|
|
57
|
+
function levenshtein(a, b) {
|
|
58
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
59
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
60
|
+
for (let i = 1; i <= a.length; i++)
|
|
61
|
+
for (let j = 1; j <= b.length; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
62
|
+
return d[a.length][b.length];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function suggest(key, options) {
|
|
66
|
+
const best = options.map((o) => [o, levenshtein(key, o)]).sort((x, y) => x[1] - y[1])[0];
|
|
67
|
+
return best && best[1] <= 3 ? ` ¿Quisiste decir "${best[0]}"?` : '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function checkKeys(obj, defaults, prefix, errors) {
|
|
71
|
+
for (const key of Object.keys(obj)) {
|
|
72
|
+
if (key === '$schema') continue;
|
|
73
|
+
if (!(key in defaults)) errors.push(`Opción desconocida "${prefix}${key}".${suggest(key, Object.keys(defaults))}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const asList = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
|
|
78
|
+
|
|
79
|
+
export function normalizeConfig(raw, errors = []) {
|
|
80
|
+
const user = raw ?? {};
|
|
81
|
+
if (typeof user !== 'object' || Array.isArray(user)) throw new BuildError('La configuración debe ser un objeto JSON.');
|
|
82
|
+
checkKeys(user, DEFAULTS, '', errors);
|
|
83
|
+
const cfg = { ...DEFAULTS, ...user };
|
|
84
|
+
for (const k of NESTED) {
|
|
85
|
+
const v = user[k];
|
|
86
|
+
if (v === false) cfg[k] = k === 'dependencies' ? { ...DEFAULTS[k], detect: false } : { ...DEFAULTS[k], minify: false };
|
|
87
|
+
else if (v === true || v == null) cfg[k] = { ...DEFAULTS[k] };
|
|
88
|
+
else if (typeof v === 'object') {
|
|
89
|
+
checkKeys(v, DEFAULTS[k], `${k}.`, errors);
|
|
90
|
+
cfg[k] = { ...DEFAULTS[k], ...v };
|
|
91
|
+
} else errors.push(`"${k}" debe ser un objeto o true/false.`);
|
|
92
|
+
}
|
|
93
|
+
for (const k of ['include', 'ignore']) {
|
|
94
|
+
cfg[k] = asList(cfg[k]);
|
|
95
|
+
if (cfg[k].some((x) => typeof x !== 'string')) errors.push(`"${k}" debe ser una lista de rutas o patrones (texto).`);
|
|
96
|
+
}
|
|
97
|
+
for (const k of ['include', 'exclude']) {
|
|
98
|
+
cfg.dependencies[k] = asList(cfg.dependencies[k]);
|
|
99
|
+
if (cfg.dependencies[k].some((x) => typeof x !== 'string')) errors.push(`"dependencies.${k}" debe ser una lista de rutas o patrones (texto).`);
|
|
100
|
+
}
|
|
101
|
+
for (const k of ['detect', 'obfuscate']) if (typeof cfg.dependencies[k] !== 'boolean') errors.push(`"dependencies.${k}" debe ser true o false.`);
|
|
102
|
+
if (cfg.entries !== 'auto') {
|
|
103
|
+
cfg.entries = asList(cfg.entries);
|
|
104
|
+
if (!cfg.entries.length || cfg.entries.some((x) => typeof x !== 'string'))
|
|
105
|
+
errors.push('"entries" debe ser "auto" o una lista de archivos/patrones.');
|
|
106
|
+
}
|
|
107
|
+
for (const [key, allowed] of Object.entries(ENUMS)) {
|
|
108
|
+
const [a, b] = key.split('.');
|
|
109
|
+
const value = b ? cfg[a][b] : cfg[a];
|
|
110
|
+
if (!allowed.includes(value)) errors.push(`"${key}" debe ser uno de: ${allowed.map((x) => JSON.stringify(x)).join(', ')}.`);
|
|
111
|
+
}
|
|
112
|
+
if (typeof cfg.outDir !== 'string' || !cfg.outDir.trim()) errors.push('"outDir" debe ser una ruta.');
|
|
113
|
+
if (typeof cfg.base !== 'string' || !cfg.base.startsWith('/')) errors.push('"base" debe empezar con "/" (ej. "/" o "/mi-sitio/").');
|
|
114
|
+
else if (!cfg.base.endsWith('/')) cfg.base += '/';
|
|
115
|
+
if (typeof cfg.hash !== 'boolean') errors.push('"hash" debe ser true o false.');
|
|
116
|
+
cfg.js.drop = asList(cfg.js.drop);
|
|
117
|
+
if (cfg.js.drop.some((d) => !['console', 'debugger'].includes(d))) errors.push('"js.drop" solo admite "console" y "debugger".');
|
|
118
|
+
if (typeof cfg.js.chunkDir !== 'string' || /(^|\/)\.\.(\/|$)|^\//.test(cfg.js.chunkDir))
|
|
119
|
+
errors.push('"js.chunkDir" debe ser una carpeta relativa.');
|
|
120
|
+
for (const k of ['css', 'js']) {
|
|
121
|
+
const t = cfg[k].target;
|
|
122
|
+
if (!(typeof t === 'string' || (Array.isArray(t) && t.every((x) => typeof x === 'string'))))
|
|
123
|
+
errors.push(`"${k}.target" debe ser texto o lista (ej. "es2020" o ["chrome90","safari15"]).`);
|
|
124
|
+
}
|
|
125
|
+
const o = cfg.obfuscate;
|
|
126
|
+
if (o === true) cfg.obfuscate = 'medium';
|
|
127
|
+
else if (!(o === false || OBF_PRESETS.includes(o) || (o && typeof o === 'object' && !Array.isArray(o))))
|
|
128
|
+
errors.push(`"obfuscate" debe ser false, true, ${OBF_PRESETS.map((p) => `"${p}"`).join(', ')} o un objeto de opciones de javascript-obfuscator.`);
|
|
129
|
+
return cfg;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readConfigFile(file) {
|
|
133
|
+
if (file.endsWith('.json')) {
|
|
134
|
+
const text = fs.readFileSync(file, 'utf8').replace(/^/, '');
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(text);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
throw new BuildError(`No pude leer ${path.basename(file)}: JSON inválido (${err.message}).`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const mod = await import(`${pathToFileURL(file).href}?t=${fs.statSync(file).mtimeMs}`);
|
|
142
|
+
return typeof mod.default === 'function' ? await mod.default() : mod.default;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function findConfigFile(dir) {
|
|
146
|
+
for (const name of CONFIG_FILES) {
|
|
147
|
+
const file = path.join(dir, name);
|
|
148
|
+
if (fs.existsSync(file)) return file;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resuelve la configuración final.
|
|
155
|
+
* @param {{dir?: string, config?: string, overrides?: object, cwd?: string}} opts
|
|
156
|
+
*/
|
|
157
|
+
export async function loadConfig({ dir = '.', config, overrides = {}, cwd = process.cwd() } = {}) {
|
|
158
|
+
const projectDir = path.resolve(cwd, dir);
|
|
159
|
+
if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) throw new BuildError(`La carpeta del proyecto no existe: ${projectDir}`);
|
|
160
|
+
const configFile = config ? path.resolve(cwd, config) : findConfigFile(projectDir);
|
|
161
|
+
if (config && !fs.existsSync(configFile)) throw new BuildError(`No existe el archivo de configuración: ${configFile}`);
|
|
162
|
+
const fileCfg = configFile ? await readConfigFile(configFile) : {};
|
|
163
|
+
const merged = mergeOverrides(fileCfg ?? {}, overrides);
|
|
164
|
+
const errors = [];
|
|
165
|
+
const cfg = normalizeConfig(merged, errors);
|
|
166
|
+
if (errors.length) throw new BuildError('La configuración tiene errores:', errors);
|
|
167
|
+
const baseDir = configFile ? path.dirname(configFile) : projectDir;
|
|
168
|
+
cfg.root = path.resolve(baseDir, cfg.root);
|
|
169
|
+
if (!fs.existsSync(cfg.root)) throw new BuildError(`"root" no existe: ${cfg.root}`);
|
|
170
|
+
// esbuild resuelve los enlaces simbólicos de lo que importa; si la raíz no es la ruta real, el mismo
|
|
171
|
+
// módulo aparece con dos rutas (pasa en macOS con /tmp → /private/tmp, o al abrir el proyecto por un enlace).
|
|
172
|
+
cfg.root = fs.realpathSync(cfg.root);
|
|
173
|
+
cfg.outDir = path.resolve(cfg.root, cfg.outDir);
|
|
174
|
+
cfg.configFile = configFile;
|
|
175
|
+
return cfg;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function mergeOverrides(base, over) {
|
|
179
|
+
const out = { ...base };
|
|
180
|
+
for (const [k, v] of Object.entries(over)) {
|
|
181
|
+
if (v === undefined) continue;
|
|
182
|
+
if (NESTED.includes(k) && v && typeof v === 'object' && !Array.isArray(v)) {
|
|
183
|
+
const prev = base[k];
|
|
184
|
+
out[k] = { ...(prev && typeof prev === 'object' ? prev : prev === false ? { minify: false } : {}), ...v };
|
|
185
|
+
} else if (['include', 'ignore'].includes(k)) {
|
|
186
|
+
out[k] = [...asList(base[k]), ...asList(v)];
|
|
187
|
+
} else out[k] = v;
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
package/src/css.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// CSS: empaqueta @import, reescribe url() según la ubicación final y minifica con esbuild.
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import * as esbuild from 'esbuild';
|
|
4
|
+
import { relFrom, toPosix } from './util.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* image-set("a.png" 1x) no lo reescribe esbuild al mover el CSS de carpeta.
|
|
8
|
+
* Se convierte a la forma equivalente image-set(url("a.png") 1x), que sí reescribe.
|
|
9
|
+
*/
|
|
10
|
+
export function imageSetStringsToUrl(css) {
|
|
11
|
+
const re = /(?:-webkit-)?image-set\(/gi;
|
|
12
|
+
let out = '';
|
|
13
|
+
let last = 0;
|
|
14
|
+
let m;
|
|
15
|
+
while ((m = re.exec(css))) {
|
|
16
|
+
let i = m.index + m[0].length;
|
|
17
|
+
let depth = 1;
|
|
18
|
+
let chunk = '';
|
|
19
|
+
let segStart = i;
|
|
20
|
+
const flush = (end) => {
|
|
21
|
+
chunk += css.slice(segStart, end);
|
|
22
|
+
};
|
|
23
|
+
while (i < css.length && depth > 0) {
|
|
24
|
+
const c = css[i];
|
|
25
|
+
if (c === '"' || c === "'") {
|
|
26
|
+
let j = i + 1;
|
|
27
|
+
while (j < css.length && css[j] !== c) j += css[j] === '\\' ? 2 : 1;
|
|
28
|
+
if (depth === 1) {
|
|
29
|
+
flush(i);
|
|
30
|
+
chunk += `url(${css.slice(i, j + 1)})`;
|
|
31
|
+
segStart = j + 1;
|
|
32
|
+
}
|
|
33
|
+
i = j + 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (c === '(') depth++;
|
|
37
|
+
else if (c === ')') depth--;
|
|
38
|
+
i++;
|
|
39
|
+
}
|
|
40
|
+
flush(i);
|
|
41
|
+
out += css.slice(last, m.index + m[0].length) + chunk;
|
|
42
|
+
last = i;
|
|
43
|
+
re.lastIndex = i;
|
|
44
|
+
}
|
|
45
|
+
return last === 0 ? css : out + css.slice(last);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
|
|
49
|
+
|
|
50
|
+
function cssPlugin(ctx, { outRel, consumed, label }) {
|
|
51
|
+
return {
|
|
52
|
+
name: 'vanilla-builder-css',
|
|
53
|
+
setup(b) {
|
|
54
|
+
b.onResolve({ filter: /.*/ }, (args) => {
|
|
55
|
+
if (args.kind === 'entry-point') return undefined;
|
|
56
|
+
const spec = args.path;
|
|
57
|
+
if (EXTERNAL.test(spec) || /\{\{|\$\{/.test(spec)) return { path: spec, external: true };
|
|
58
|
+
const isStdin = !args.importer || args.importer === '<stdin>';
|
|
59
|
+
const fromRel = isStdin ? label : (relFrom(ctx.root, args.importer) ?? label);
|
|
60
|
+
const baseDir = args.resolveDir ? (relFrom(ctx.root, args.resolveDir) ?? '') : path.posix.dirname(fromRel);
|
|
61
|
+
const r = ctx.resolve(spec, baseDir);
|
|
62
|
+
if (r.status !== 'ok') {
|
|
63
|
+
ctx.reportBroken(fromRel, spec, r);
|
|
64
|
+
return { path: spec, external: true };
|
|
65
|
+
}
|
|
66
|
+
if (args.kind === 'import-rule') return { path: ctx.abs(r.rel) };
|
|
67
|
+
// url(): el archivo se copia a su ruta final y la URL se recalcula desde el CSS de salida.
|
|
68
|
+
ctx.registerAsset(r.rel, fromRel);
|
|
69
|
+
const target = ctx.assetOut(r.rel);
|
|
70
|
+
let url = path.posix.relative(path.posix.dirname(outRel), target);
|
|
71
|
+
if (!url) url = path.posix.basename(target);
|
|
72
|
+
url = url
|
|
73
|
+
.split('/')
|
|
74
|
+
.map((s) => (s === '..' ? s : encodeURIComponent(s)))
|
|
75
|
+
.join('/');
|
|
76
|
+
return { path: url + r.info.suffix, external: true };
|
|
77
|
+
});
|
|
78
|
+
b.onLoad({ filter: /\.css$/i }, (args) => {
|
|
79
|
+
const rel = relFrom(ctx.root, args.path);
|
|
80
|
+
if (rel == null) return undefined;
|
|
81
|
+
consumed.add(rel);
|
|
82
|
+
const { text } = ctx.readText(rel, { kind: 'css' });
|
|
83
|
+
return { contents: imageSetStringsToUrl(text), loader: 'css' };
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Compila una hoja de estilos.
|
|
91
|
+
* @param {object} ctx contexto del builder
|
|
92
|
+
* @param {{entry?: string, inline?: {code: string, baseDir: string, label: string}, outRel: string}} job
|
|
93
|
+
*/
|
|
94
|
+
export async function buildCss(ctx, job) {
|
|
95
|
+
const cfg = ctx.cfg;
|
|
96
|
+
const consumed = new Set();
|
|
97
|
+
const common = {
|
|
98
|
+
bundle: cfg.css.bundle,
|
|
99
|
+
write: false,
|
|
100
|
+
minify: cfg.css.minify,
|
|
101
|
+
target: cfg.css.target,
|
|
102
|
+
logLevel: 'silent',
|
|
103
|
+
absWorkingDir: ctx.root,
|
|
104
|
+
outfile: path.join(ctx.root, '__vb_out__', job.outRel),
|
|
105
|
+
plugins: [cssPlugin(ctx, { outRel: job.outRel, consumed, label: job.entry ?? job.inline?.label })],
|
|
106
|
+
legalComments: cfg.css.minify ? cfg.js.legalComments : 'inline',
|
|
107
|
+
charset: 'ascii',
|
|
108
|
+
sourcemap: job.inline
|
|
109
|
+
? false
|
|
110
|
+
: cfg.sourcemap === 'hidden'
|
|
111
|
+
? 'external'
|
|
112
|
+
: cfg.sourcemap === 'inline'
|
|
113
|
+
? 'inline'
|
|
114
|
+
: cfg.sourcemap
|
|
115
|
+
? 'linked'
|
|
116
|
+
: false,
|
|
117
|
+
};
|
|
118
|
+
let result;
|
|
119
|
+
try {
|
|
120
|
+
result = job.inline
|
|
121
|
+
? await esbuild.build({
|
|
122
|
+
...common,
|
|
123
|
+
stdin: {
|
|
124
|
+
contents: imageSetStringsToUrl(job.inline.code),
|
|
125
|
+
resolveDir: ctx.abs(job.inline.baseDir),
|
|
126
|
+
loader: 'css',
|
|
127
|
+
sourcefile: job.inline.label,
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
: await esbuild.build({ ...common, entryPoints: [ctx.abs(job.entry)] });
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return { error: esbuildErrorText(err, ctx.root) };
|
|
133
|
+
}
|
|
134
|
+
for (const w of result.warnings) ctx.warn(`CSS: ${formatMessage(w, ctx.root)}`, job.entry ?? job.inline?.label);
|
|
135
|
+
let code = '';
|
|
136
|
+
let map = null;
|
|
137
|
+
for (const f of result.outputFiles) {
|
|
138
|
+
if (f.path.endsWith('.map')) map = f.text;
|
|
139
|
+
else code = f.text;
|
|
140
|
+
}
|
|
141
|
+
if (job.inline) code = code.replace(/\n$/, '');
|
|
142
|
+
return { code, map, inputs: [...consumed] };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Minifica el contenido de un atributo style="" sin tocar sus URLs. */
|
|
146
|
+
export async function minifyStyleAttr(value, target) {
|
|
147
|
+
if (!value.trim()) return value;
|
|
148
|
+
try {
|
|
149
|
+
const r = await esbuild.transform(`*{${value}}`, { loader: 'css', minify: true, target, charset: 'ascii', logLevel: 'silent' });
|
|
150
|
+
const m = /^\*\{([\s\S]*)\}\s*$/.exec(r.code);
|
|
151
|
+
if (!m || r.warnings.length) return value;
|
|
152
|
+
return m[1];
|
|
153
|
+
} catch {
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function formatMessage(m, root) {
|
|
159
|
+
const loc = m.location ? `${toPosix(path.relative(root, path.resolve(root, m.location.file)))}:${m.location.line}:${m.location.column + 1} ` : '';
|
|
160
|
+
return `${loc}${m.text}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function esbuildErrorText(err, root) {
|
|
164
|
+
if (err?.errors?.length) return err.errors.map((e) => formatMessage(e, root)).join('\n');
|
|
165
|
+
return err?.message ?? String(err);
|
|
166
|
+
}
|
package/src/deps.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Detección de librerías de terceros (jQuery, Bootstrap, paquetes de npm...).
|
|
2
|
+
// No se ofuscan: no aporta nada, las hace más lentas y puede romper su licencia.
|
|
3
|
+
|
|
4
|
+
const DEP_DIRS = /(^|\/)(node_modules|bower_components|jspm_packages|web_modules|vendors?|third[-_]?party|external|externals)\//i;
|
|
5
|
+
const MIN_NAME = /[.-](min|prod|production)\.m?js$|\.umd(\.min)?\.m?js$/i;
|
|
6
|
+
// Nombres de librerías conocidas, con o sin versión: jquery-3.7.1.js, bootstrap.bundle.js, chart.umd.js...
|
|
7
|
+
const KNOWN = new RegExp(
|
|
8
|
+
`^(${[
|
|
9
|
+
'jquery(?:[.-]ui)?',
|
|
10
|
+
'zepto',
|
|
11
|
+
'bootstrap',
|
|
12
|
+
'popper',
|
|
13
|
+
'@popperjs',
|
|
14
|
+
'lodash',
|
|
15
|
+
'underscore',
|
|
16
|
+
'moment(?:-with-locales)?',
|
|
17
|
+
'dayjs',
|
|
18
|
+
'luxon',
|
|
19
|
+
'd3',
|
|
20
|
+
'three',
|
|
21
|
+
'vue',
|
|
22
|
+
'react',
|
|
23
|
+
'react-dom',
|
|
24
|
+
'preact',
|
|
25
|
+
'angular',
|
|
26
|
+
'alpine',
|
|
27
|
+
'alpinejs',
|
|
28
|
+
'htmx',
|
|
29
|
+
'axios',
|
|
30
|
+
'gsap',
|
|
31
|
+
'swiper',
|
|
32
|
+
'leaflet',
|
|
33
|
+
'mapbox-gl',
|
|
34
|
+
'sweetalert2?',
|
|
35
|
+
'toastr',
|
|
36
|
+
'select2',
|
|
37
|
+
'datatables',
|
|
38
|
+
'jquery\\.dataTables',
|
|
39
|
+
'fullcalendar',
|
|
40
|
+
'prism',
|
|
41
|
+
'marked',
|
|
42
|
+
'dompurify',
|
|
43
|
+
'handlebars',
|
|
44
|
+
'mustache',
|
|
45
|
+
'backbone',
|
|
46
|
+
'knockout',
|
|
47
|
+
'socket\\.io',
|
|
48
|
+
'pdf(?:\\.worker)?',
|
|
49
|
+
'tinymce',
|
|
50
|
+
'ckeditor',
|
|
51
|
+
'quill',
|
|
52
|
+
'lottie',
|
|
53
|
+
'fontawesome',
|
|
54
|
+
'modernizr',
|
|
55
|
+
'core-js',
|
|
56
|
+
'regenerator-runtime',
|
|
57
|
+
'tailwind(?:css)?',
|
|
58
|
+
'bulma',
|
|
59
|
+
'uikit',
|
|
60
|
+
'materialize',
|
|
61
|
+
'fancybox',
|
|
62
|
+
'slick',
|
|
63
|
+
'owl\\.carousel',
|
|
64
|
+
'isotope',
|
|
65
|
+
'masonry',
|
|
66
|
+
'imagesloaded',
|
|
67
|
+
'inputmask',
|
|
68
|
+
'flatpickr',
|
|
69
|
+
'pikaday',
|
|
70
|
+
'nouislider',
|
|
71
|
+
'requirejs',
|
|
72
|
+
'es-module-shims',
|
|
73
|
+
'workbox-[\\w-]+',
|
|
74
|
+
'firebase(?:-[\\w-]+)?',
|
|
75
|
+
'plyr',
|
|
76
|
+
'howler',
|
|
77
|
+
'pixi',
|
|
78
|
+
'phaser',
|
|
79
|
+
'konva',
|
|
80
|
+
'echarts',
|
|
81
|
+
'apexcharts',
|
|
82
|
+
'highcharts',
|
|
83
|
+
'plotly',
|
|
84
|
+
'mathjax',
|
|
85
|
+
'katex',
|
|
86
|
+
'mermaid',
|
|
87
|
+
'codemirror',
|
|
88
|
+
'monaco',
|
|
89
|
+
'xlsx',
|
|
90
|
+
'jszip',
|
|
91
|
+
'filesaver',
|
|
92
|
+
'jspdf',
|
|
93
|
+
'html2canvas',
|
|
94
|
+
'qrcode',
|
|
95
|
+
'crypto-js',
|
|
96
|
+
'immutable',
|
|
97
|
+
'rxjs',
|
|
98
|
+
'mobx',
|
|
99
|
+
'redux',
|
|
100
|
+
'i18next',
|
|
101
|
+
'intl-tel-input',
|
|
102
|
+
'libphonenumber',
|
|
103
|
+
'cropper',
|
|
104
|
+
'dropzone',
|
|
105
|
+
'tippy',
|
|
106
|
+
'micromodal',
|
|
107
|
+
'glightbox',
|
|
108
|
+
'splide',
|
|
109
|
+
'flickity',
|
|
110
|
+
'scrollreveal',
|
|
111
|
+
'locomotive-scroll',
|
|
112
|
+
'lenis',
|
|
113
|
+
'barba',
|
|
114
|
+
'swup',
|
|
115
|
+
].join('|')})(?:[.@_-][\\w.-]*)?\\.m?js$`,
|
|
116
|
+
'i',
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* ¿Parece una librería de terceros? Devuelve el motivo o null.
|
|
121
|
+
* @param {string} rel ruta relativa (POSIX)
|
|
122
|
+
* @param {string} code contenido
|
|
123
|
+
*/
|
|
124
|
+
export function detectDependency(rel, code = '') {
|
|
125
|
+
if (DEP_DIRS.test(rel)) return `está en ${rel.match(DEP_DIRS)[2]}/`;
|
|
126
|
+
const name = rel.split('/').pop();
|
|
127
|
+
if (KNOWN.test(name)) return 'nombre de librería conocida';
|
|
128
|
+
if (MIN_NAME.test(name)) return 'versión ya compilada (.min.js / .umd.js)';
|
|
129
|
+
// Cabecera de librería publicada: /*! o @license con número de versión (jQuery v3.7.1, Bootstrap v5.3.0...).
|
|
130
|
+
// Una cabecera propia sin versión ("/*! Mi app (c) 2026 */") no basta.
|
|
131
|
+
const header = /^\s*\/\*[\s\S]*?\*\//.exec(code.slice(0, 3000))?.[0] ?? '';
|
|
132
|
+
if ((/^\s*\/\*!/.test(header) || /@(license|preserve)\b/.test(header)) && /\bv?\d+\.\d+(\.\d+)?\b/.test(header))
|
|
133
|
+
return 'cabecera de licencia con versión';
|
|
134
|
+
// Código ya minificado: pocas líneas muy largas.
|
|
135
|
+
if (code.length > 4000) {
|
|
136
|
+
const lines = code.split('\n').length;
|
|
137
|
+
if (code.length / lines > 500) return 'código ya minificado';
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
package/src/files.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Recorre el proyecto y decide qué archivos forman parte del sitio.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import picomatch from 'picomatch';
|
|
5
|
+
import { CONFIG_FILES } from './config.js';
|
|
6
|
+
import { relFrom, toPosix, extOf } from './util.js';
|
|
7
|
+
|
|
8
|
+
/** Archivos de desarrollo que nunca deberían publicarse. Se desactiva con defaultIgnore:false. */
|
|
9
|
+
export const DEFAULT_IGNORE = [
|
|
10
|
+
'**/node_modules/**',
|
|
11
|
+
'**/.git/**',
|
|
12
|
+
'**/.svn/**',
|
|
13
|
+
'**/.hg/**',
|
|
14
|
+
'**/.vscode/**',
|
|
15
|
+
'**/.idea/**',
|
|
16
|
+
'**/.DS_Store',
|
|
17
|
+
'**/Thumbs.db',
|
|
18
|
+
'**/desktop.ini',
|
|
19
|
+
'**/*.log',
|
|
20
|
+
'package.json',
|
|
21
|
+
'package-lock.json',
|
|
22
|
+
'npm-shrinkwrap.json',
|
|
23
|
+
'yarn.lock',
|
|
24
|
+
'pnpm-lock.yaml',
|
|
25
|
+
'bun.lockb',
|
|
26
|
+
'.gitignore',
|
|
27
|
+
'.gitattributes',
|
|
28
|
+
'.editorconfig',
|
|
29
|
+
'.npmrc',
|
|
30
|
+
'.nvmrc',
|
|
31
|
+
'**/.prettierrc*',
|
|
32
|
+
'**/.eslintrc*',
|
|
33
|
+
'**/eslint.config.*',
|
|
34
|
+
'**/prettier.config.*',
|
|
35
|
+
'tsconfig.json',
|
|
36
|
+
'jsconfig.json',
|
|
37
|
+
'**/*.test.js',
|
|
38
|
+
'**/*.spec.js',
|
|
39
|
+
'**/__tests__/**',
|
|
40
|
+
'**/*.map',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Secretos: se excluyen siempre por defecto y se avisa. */
|
|
44
|
+
export const SECRET_PATTERNS = ['**/.env', '**/.env.*', '**/*.pem', '**/*.key', '**/id_rsa*'];
|
|
45
|
+
|
|
46
|
+
/** "carpeta/" equivale a "carpeta/**"; "./x" a "x". */
|
|
47
|
+
const normalizePattern = (p) => {
|
|
48
|
+
const s = toPosix(p).replace(/^\.\//, '');
|
|
49
|
+
return s.endsWith('/') ? `${s}**` : s;
|
|
50
|
+
};
|
|
51
|
+
const matcher = (patterns) => (patterns.length ? picomatch(patterns.map(normalizePattern), { dot: true, nocase: true }) : () => false);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Lista los archivos del proyecto con el motivo de exclusión de cada uno.
|
|
55
|
+
* @returns {{files: Map<string, {rel:string, abs:string, size:number, ext:string}>, ignored: Array<{rel:string, reason:string}>}}
|
|
56
|
+
*/
|
|
57
|
+
export function scanProject(cfg) {
|
|
58
|
+
const root = cfg.root;
|
|
59
|
+
const outRel = relFrom(root, cfg.outDir);
|
|
60
|
+
const builtIn = [...CONFIG_FILES];
|
|
61
|
+
if (outRel) builtIn.push(`${outRel}/**`, outRel);
|
|
62
|
+
if (cfg.configFile) {
|
|
63
|
+
const rel = relFrom(root, cfg.configFile);
|
|
64
|
+
if (rel) builtIn.push(rel);
|
|
65
|
+
}
|
|
66
|
+
// Carpetas temporales de compilaciones anteriores interrumpidas.
|
|
67
|
+
builtIn.push('**/.*.builder-tmp-*/**', '**/.*.builder-old-*/**');
|
|
68
|
+
const isBuiltIn = matcher(builtIn);
|
|
69
|
+
const isDefault = cfg.defaultIgnore ? matcher(DEFAULT_IGNORE) : () => false;
|
|
70
|
+
const isSecret = cfg.defaultIgnore ? matcher(SECRET_PATTERNS) : () => false;
|
|
71
|
+
const isUser = matcher(cfg.ignore);
|
|
72
|
+
const isIncluded = matcher(cfg.include);
|
|
73
|
+
|
|
74
|
+
const files = new Map();
|
|
75
|
+
const ignored = [];
|
|
76
|
+
const walk = (dir) => {
|
|
77
|
+
let entries;
|
|
78
|
+
try {
|
|
79
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
80
|
+
} catch (err) {
|
|
81
|
+
ignored.push({ rel: relFrom(root, dir) || '.', reason: `no se pudo leer (${err.code})` });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const abs = path.join(dir, entry.name);
|
|
87
|
+
const rel = relFrom(root, abs);
|
|
88
|
+
let isDir = entry.isDirectory();
|
|
89
|
+
let isFile = entry.isFile();
|
|
90
|
+
if (entry.isSymbolicLink()) {
|
|
91
|
+
// Se sigue el enlace a archivos; no a carpetas (evita ciclos).
|
|
92
|
+
try {
|
|
93
|
+
const st = fs.statSync(abs);
|
|
94
|
+
isFile = st.isFile();
|
|
95
|
+
isDir = false;
|
|
96
|
+
if (st.isDirectory()) ignored.push({ rel, reason: 'enlace simbólico a carpeta (no se sigue)' });
|
|
97
|
+
} catch {
|
|
98
|
+
ignored.push({ rel, reason: 'enlace simbólico roto' });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (isBuiltIn(rel) || isBuiltIn(`${rel}/`)) continue;
|
|
103
|
+
if (isDir) {
|
|
104
|
+
// Las carpetas excluidas no se recorren, salvo que include apunte dentro.
|
|
105
|
+
const probe = `${rel}/__probe__`;
|
|
106
|
+
const byUser = isUser(rel) || isUser(probe);
|
|
107
|
+
const wanted = cfg.include.some((p) => normalizePattern(p).startsWith(`${rel}/`) || p.startsWith('**'));
|
|
108
|
+
if ((byUser || isDefault(probe)) && !wanted) {
|
|
109
|
+
ignored.push({ rel: `${rel}/`, reason: byUser ? 'ignore (configuración)' : 'excluido por defecto' });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
walk(abs);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!isFile) continue;
|
|
116
|
+
const forced = isIncluded(rel);
|
|
117
|
+
let reason = null;
|
|
118
|
+
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';
|
|
122
|
+
}
|
|
123
|
+
if (reason) {
|
|
124
|
+
ignored.push({ rel, reason });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const size = fs.statSync(abs).size;
|
|
128
|
+
files.set(rel, { rel, abs, size, ext: extOf(rel), forced });
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
walk(root);
|
|
132
|
+
return { files, ignored };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Resuelve patrones (o rutas exactas) contra los archivos del proyecto. */
|
|
136
|
+
export function matchFiles(files, patterns) {
|
|
137
|
+
const out = [];
|
|
138
|
+
const missing = [];
|
|
139
|
+
for (const pattern of patterns) {
|
|
140
|
+
const norm = toPosix(pattern).replace(/^\.\//, '');
|
|
141
|
+
if (picomatch.scan(norm).isGlob) {
|
|
142
|
+
const isMatch = picomatch(norm, { dot: true, nocase: true });
|
|
143
|
+
const hits = [...files.keys()].filter((rel) => isMatch(rel));
|
|
144
|
+
if (!hits.length) missing.push(pattern);
|
|
145
|
+
out.push(...hits);
|
|
146
|
+
} else if (files.has(norm)) out.push(norm);
|
|
147
|
+
else missing.push(pattern);
|
|
148
|
+
}
|
|
149
|
+
return { matched: [...new Set(out)], missing };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const globMatcher = matcher;
|