@vernikr/size-report 1.2.0 → 1.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/CHANGELOG.md +56 -0
- package/README.md +168 -67
- package/package.json +21 -2
- package/src/args.js +191 -0
- package/src/artifact.js +9 -2
- package/src/check.js +8 -10
- package/src/cli.js +47 -457
- package/src/config.js +103 -24
- package/src/data.js +16 -9
- package/src/derived.js +3 -2
- package/src/doctor.js +121 -65
- package/src/explain.js +43 -26
- package/src/history.js +94 -77
- package/src/hook.js +17 -11
- package/src/init.js +75 -0
- package/src/modes.js +190 -0
- package/src/page/app.js +9 -498
- package/src/page/build.js +16 -7
- package/src/page/dom.js +22 -0
- package/src/page/panel.js +124 -0
- package/src/page/state.js +226 -0
- package/src/page/table.js +145 -0
- package/src/project.js +262 -0
- package/src/refusal.js +6 -1
- package/src/render.js +19 -11
- package/src/size-table.js +10 -4
- package/src/strip/forms.js +26 -0
- package/src/strip/guard.js +59 -0
- package/src/strip/js.js +136 -0
- package/src/strip.js +17 -190
- package/templates/README.md +16 -10
package/src/project.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
4
|
+
import { MAX_BUF, git, gitArgv, gitEnv, gitTry, readHistory } from './git.js';
|
|
5
|
+
import { cliCommand, invocation } from './refusal.js';
|
|
6
|
+
|
|
7
|
+
/* Настройки, выведенные из самого проекта: что считать, где журнал, куда писать и
|
|
8
|
+
* что колонкой быть не может.
|
|
9
|
+
*
|
|
10
|
+
* Зачем отдельно от `src/config.js`. Тот читает **готовые** настройки и только
|
|
11
|
+
* затем их проверяет, а этот смотрит на проект впервые и почти всё о нём
|
|
12
|
+
* догадывается — та же работа, что была у черновика (`--init`). Отсюда две роли
|
|
13
|
+
* одного вывода: без файла настроек он и есть настройки (проект работает сразу,
|
|
14
|
+
* ничего не заводив), а с `--init` он же ложится файлом, который дальше правят.
|
|
15
|
+
* Второго вывода «как угадать проект» в пакете нет.
|
|
16
|
+
*
|
|
17
|
+
* Два правила, из которых выведено всё остальное.
|
|
18
|
+
*
|
|
19
|
+
* **Колонка — это файл.** Состояние движка хранит на колонку один путь, а список
|
|
20
|
+
* путей колонки — её переименования (в ревизии берётся тот, который в ней есть).
|
|
21
|
+
* Поэтому «папка целиком» колонкой быть не может, и профиль выбирает файлы, а не
|
|
22
|
+
* группы путей.
|
|
23
|
+
*
|
|
24
|
+
* **Профиль обязан проходить ту же проверку, которой его встретит первый запуск**
|
|
25
|
+
* (BLOCKERS §N2, REFACTOR R-0.4): путь, не ставший колонкой и не объявленный
|
|
26
|
+
* исключением, — это код 1 на первом же прогоне. Поэтому `skip` называет и то,
|
|
27
|
+
* что колонкой быть не может (сам отчёт, замки зависимостей, карты, собранное), и
|
|
28
|
+
* то, что в колонки не поместилось: так первый `check` зелёный, а что не попало в
|
|
29
|
+
* отчёт — сказано в файле, а не умолчано.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/* Что вообще берётся колонкой: текстовые формы, с которыми движок умеет работать.
|
|
33
|
+
* Файл без знакомого расширения (`LICENSE`, `.gitignore`) колонкой не становится и
|
|
34
|
+
* называется исключением. */
|
|
35
|
+
const KNOWN_EXTS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.html', '.css', '.scss',
|
|
36
|
+
'.json', '.yaml', '.yml', '.toml', '.md', '.txt', '.py', '.rb', '.go', '.rs', '.sh'];
|
|
37
|
+
|
|
38
|
+
const JOURNALS = ['WORKLOG.md', 'CHANGELOG.md', 'CHANGES.md', 'HISTORY.md'];
|
|
39
|
+
|
|
40
|
+
/* Порог по размеру — предохранитель: очень крупный файл в истории обычно собран или
|
|
41
|
+
* сгенерирован, и в отчёте он перевесил бы весь проект. */
|
|
42
|
+
const MAX_BYTES = 512 * 1024;
|
|
43
|
+
|
|
44
|
+
/* Сколько файлов идут колонками: отчёт читают глазами, а сотня колонок читается
|
|
45
|
+
* хуже, чем скачок. Остальное остаётся на виду — в `skip`, откуда его берут
|
|
46
|
+
* колонкой, когда оно важно. */
|
|
47
|
+
const COLUMN_LIMIT = 12;
|
|
48
|
+
|
|
49
|
+
/* Индекс — путь и размер: `ls-files -s` даёт объекты, размер спрашивается у них
|
|
50
|
+
* одной пачкой (`cat-file --batch-check`), а не чтением содержимого. */
|
|
51
|
+
function indexFiles(root) {
|
|
52
|
+
const listed = git(root, ['ls-files', '-s']).split('\n').filter((l) => l !== '');
|
|
53
|
+
const sizes = new Map();
|
|
54
|
+
const shas = listed.map((l) => l.split(/\s+/)[1]);
|
|
55
|
+
if (shas.length > 0) {
|
|
56
|
+
const checked = execFileSync('git', gitArgv(['cat-file', '--batch-check=%(objectname)\t%(objectsize)']), {
|
|
57
|
+
cwd: root, encoding: 'utf8', input: shas.join('\n') + '\n', maxBuffer: MAX_BUF, env: gitEnv()
|
|
58
|
+
});
|
|
59
|
+
checked.split('\n').forEach((l) => {
|
|
60
|
+
const [sha, size] = l.split('\t');
|
|
61
|
+
sizes.set(sha, Number(size));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return listed.map((line) => ({ p: line.split('\t')[1], size: sizes.get(line.split(/\s+/)[1]) || 0 }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* История — объединение путей всех коммитов, тем же чтением, которым пользуется
|
|
68
|
+
* полнота (`readHistory`): иначе профиль и `check` считали бы разные истории. Файл,
|
|
69
|
+
* живущий только в истории, в отчёте просто пуст на HEAD — это не потеря, а факт, и
|
|
70
|
+
* колонкой (или исключением) он быть обязан. */
|
|
71
|
+
function historyPaths(root) {
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
try {
|
|
74
|
+
readHistory(root).forEach((c) => c.files.forEach((f) => seen.add(f)));
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// У репозитория без коммитов истории нет вовсе: `git log` там отказывает, и
|
|
77
|
+
// это состояние проекта, а не дефект инструмента — `--init` обязан работать и
|
|
78
|
+
// в нём (первый запуск бывает и до первого коммита).
|
|
79
|
+
if (/does not have any commits/.test(String(e.stderr))) return seen;
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
return seen;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Дерево и история — одним списком: колонкой становится всё, что тронула история.
|
|
86
|
+
function allPaths(root) {
|
|
87
|
+
const files = indexFiles(root);
|
|
88
|
+
const known = new Set(files.map((f) => f.p));
|
|
89
|
+
historyPaths(root).forEach((p) => {
|
|
90
|
+
if (!known.has(p)) files.push({ p: p, size: 0 });
|
|
91
|
+
});
|
|
92
|
+
return files;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Наличие каталога решает, куда ляжет отчёт: рядом с доками или в корне.
|
|
96
|
+
function exists(root, p) {
|
|
97
|
+
return fs.existsSync(path.join(root, p));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function outputOf(root) {
|
|
101
|
+
return (exists(root, 'docs') ? 'docs/' : '') + 'size-table.html';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* Менеджер пакетов — по lock-файлу, а не догадкой: команда обязана существовать
|
|
105
|
+
* в чужом проекте. Один на два места (команду починки и подсказку `--init`). */
|
|
106
|
+
export function packageManager(root) {
|
|
107
|
+
if (exists(root, 'pnpm-lock.yaml')) return 'pnpm';
|
|
108
|
+
return exists(root, 'yarn.lock') ? 'yarn' : 'npm';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readJson(file) {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
114
|
+
} catch (_e) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* Подпись артефакта и отказы цитируют эту команду, поэтому она обязана работать
|
|
120
|
+
* здесь и сейчас: свой скрипт — только если он объявлен (иначе `npm run sizes`
|
|
121
|
+
* отвечает «нет такого скрипта», а совет, который не работает, — худшая подсказка),
|
|
122
|
+
* иначе — установленный пакет внутри проекта. Имени пакета в команде нет намеренно:
|
|
123
|
+
* `npx <имя>` без установленного пакета уходит в реестр и запускает чужой код
|
|
124
|
+
* (REFACTOR R-4.21). */
|
|
125
|
+
function fixCommandOf(root) {
|
|
126
|
+
const pkg = readJson(path.join(root, 'package.json')) || {};
|
|
127
|
+
const script = pkg.scripts === undefined ? '' : pkg.scripts.sizes;
|
|
128
|
+
if (typeof script !== 'string' || script === '') return invocation() + ' --write';
|
|
129
|
+
return packageManager(root) + ' run sizes';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Ссылка на коммит — из адреса origin: два хозяина собирают её одним правилом из
|
|
133
|
+
* владельца и пути к репозиторию (у GitLab в пути бывают подгруппы, поэтому путь
|
|
134
|
+
* берётся целиком), а всякий третий хозяин — догадка, которая ведёт не туда.
|
|
135
|
+
* Remote нет или он не тот — ссылок нет: пустой шаблон строка просто не несёт. */
|
|
136
|
+
const REMOTE_RE = /^\S+?(?:@|\/\/)(?:[^@/]*@)?(github\.com|gitlab\.com)[/:]+((?:[^/\s]+\/)*[^/\s]+?)(?:\.git)?$/;
|
|
137
|
+
|
|
138
|
+
function commitUrlOf(root) {
|
|
139
|
+
const m = gitTry(root, ['remote', 'get-url', 'origin']).stdout.trim().match(REMOTE_RE);
|
|
140
|
+
if (m === null) return '';
|
|
141
|
+
const shape = m[1] === 'github.com' ? '/commit/{sha}' : '/-/commit/{sha}';
|
|
142
|
+
return 'https://' + m[1] + '/' + m[2] + shape;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function journalOf(root) {
|
|
146
|
+
return JOURNALS.find((p) => exists(root, p)) || '';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Колонкой не становится: сам отчёт (его размер зависит от числа строк, то есть от
|
|
150
|
+
* себя), замки зависимостей (их размер — про чужие пакеты), карты и собранное (их
|
|
151
|
+
* сделала сборка, а не человек) и всё, чего движок не умеет читать. */
|
|
152
|
+
function generated(p, output) {
|
|
153
|
+
const ext = path.extname(p).toLowerCase();
|
|
154
|
+
return p === output || KNOWN_EXTS.indexOf(ext) < 0
|
|
155
|
+
|| /(?:^|\/)(?:package-lock\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json|yarn\.lock|composer\.lock|bun\.lockb)$/.test(p)
|
|
156
|
+
|| /\.min\./.test(p) || /\.map$/.test(p);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* Метка колонки — имя файла; совпадение имён в разных папках разводится путём, а
|
|
160
|
+
* если и путь занят — числом. Повтор метки проверка настроек не пропустит. */
|
|
161
|
+
function labelFor(used, p) {
|
|
162
|
+
const candidates = [path.basename(p), p];
|
|
163
|
+
const free = candidates.find((name) => used.indexOf(name) < 0);
|
|
164
|
+
if (free !== undefined) return free;
|
|
165
|
+
let n = 2;
|
|
166
|
+
while (used.indexOf(p + ' (' + n + ')') >= 0) n++;
|
|
167
|
+
return p + ' (' + n + ')';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* Выбор колонок: по кругу от каждого расширения берётся его крупнейший файл — иначе
|
|
171
|
+
* в отчёте оказались бы одни крупные `.md`, и ни один `.js` не попал бы под
|
|
172
|
+
* настоящее сжатие (первый отчёт нового проекта обязан быть точным, а не
|
|
173
|
+
* приближённым). Порядок круга — по весу расширения: сперва то, чего в проекте
|
|
174
|
+
* больше всего. */
|
|
175
|
+
function columnsOf(files, journal) {
|
|
176
|
+
const byExt = new Map();
|
|
177
|
+
files.forEach((f) => {
|
|
178
|
+
const ext = path.extname(f.p).toLowerCase();
|
|
179
|
+
const list = byExt.get(ext) || [];
|
|
180
|
+
list.push(f);
|
|
181
|
+
byExt.set(ext, list);
|
|
182
|
+
});
|
|
183
|
+
const rings = [...byExt.values()].map((list) => list.sort((a, b) => b.size - a.size));
|
|
184
|
+
const used = [];
|
|
185
|
+
const columns = [];
|
|
186
|
+
const take = (p) => {
|
|
187
|
+
columns.push({ label: labelFor(used, p), paths: [p] });
|
|
188
|
+
used.push(columns[columns.length - 1].label);
|
|
189
|
+
};
|
|
190
|
+
if (journal !== '' && files.some((f) => f.p === journal)) take(journal);
|
|
191
|
+
for (let round = 0; columns.length < COLUMN_LIMIT; round++) {
|
|
192
|
+
const before = columns.length;
|
|
193
|
+
rings.forEach((ring) => {
|
|
194
|
+
const f = ring[round];
|
|
195
|
+
if (f !== undefined && columns.length < COLUMN_LIMIT && f.p !== journal) take(f.p);
|
|
196
|
+
});
|
|
197
|
+
if (columns.length === before) break;
|
|
198
|
+
}
|
|
199
|
+
return columns;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function projectConfig(root) {
|
|
203
|
+
const output = outputOf(root);
|
|
204
|
+
const journal = journalOf(root);
|
|
205
|
+
const files = allPaths(root);
|
|
206
|
+
const readable = files.filter((f) => !generated(f.p, output) && f.size <= MAX_BYTES);
|
|
207
|
+
const columns = columnsOf(readable, journal);
|
|
208
|
+
const taken = new Set(columns.reduce((all, c) => all.concat(c.paths), []));
|
|
209
|
+
// Язык, заголовок, порядок строк и выключатель хука не выводятся ни из чего:
|
|
210
|
+
// они берутся из умолчаний (`DEFAULT_CONFIG`), а не выдаются за вывод из проекта.
|
|
211
|
+
return {
|
|
212
|
+
output: output,
|
|
213
|
+
fixCommand: fixCommandOf(root),
|
|
214
|
+
metrics: ['raw', 'min', 'tok'],
|
|
215
|
+
// Настоящее сжатие и настоящий словарь, а не приближения: новый проект не должен
|
|
216
|
+
// начинать с чисел, которые честны наполовину. Без необязательной зависимости
|
|
217
|
+
// метрика отступает к другому счёту и прогон возвращает код 4 — это сказано
|
|
218
|
+
// подписью метрики, а не умолчанием.
|
|
219
|
+
minify: { engine: 'esbuild' },
|
|
220
|
+
tokens: { family: 'openai', encoding: 'o200k_base' },
|
|
221
|
+
columns: columns,
|
|
222
|
+
journal: journal === '' ? null : {
|
|
223
|
+
path: journal,
|
|
224
|
+
url: (path.dirname(output) === '.' ? '' : '../') + journal,
|
|
225
|
+
pattern: '^## (?<id>\\S+)\\s+(?<title>.+?)\\s*$',
|
|
226
|
+
anchor: 'heading'
|
|
227
|
+
},
|
|
228
|
+
links: { commitUrl: commitUrlOf(root) },
|
|
229
|
+
// Исключения — всё, что колонкой не стало: и то, что ею быть не может, и то, что
|
|
230
|
+
// в отчёт не поместилось. Так первый запуск полон, а непоместившееся названо.
|
|
231
|
+
skip: [output].concat(files.filter((f) => !taken.has(f.p)).map((f) => f.p))
|
|
232
|
+
.filter((p, i, all) => all.indexOf(p) === i)
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* Имя черновика (`sniffColumns`) осталось публичным: на него опираются те, кто звал
|
|
237
|
+
* вывод колонок (`test/api.test.js` держит список имён), и форма ответа та же —
|
|
238
|
+
* колонки, знакомые расширения проекта и сколько путей всего. */
|
|
239
|
+
export function sniffColumns(root) {
|
|
240
|
+
const files = allPaths(root);
|
|
241
|
+
const exts = [...new Set(files.map((f) => path.extname(f.p).toLowerCase()))]
|
|
242
|
+
.filter((e) => KNOWN_EXTS.indexOf(e) >= 0).sort();
|
|
243
|
+
return { columns: projectConfig(root).columns, exts: exts, total: files.length };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* Что сказать человеку, когда настроек нет и работать пришлось на выведенных: одна
|
|
247
|
+
* строка про то, что вышло, и одна — про то, чем это закрепить. Текст — тем же
|
|
248
|
+
* списком, что и находка `doctor`: два ответа об одном не должны разойтись словами. */
|
|
249
|
+
export function derivedSummary(cfg) {
|
|
250
|
+
const labels = cfg.columns.map((c) => c.label);
|
|
251
|
+
return 'настройки выведены из проекта (файла нет): колонок ' + cfg.columns.length
|
|
252
|
+
+ ' (' + labels.slice(0, 5).join(', ') + (labels.length > 5 ? ', …' : '') + '),'
|
|
253
|
+
+ ' исключено путей ' + cfg.skip.length;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function derivedLines(cfg) {
|
|
257
|
+
return [
|
|
258
|
+
'! ' + derivedSummary(cfg),
|
|
259
|
+
' закрепить их файлом (дальше правьте как удобно; иначе состав колонок меняется'
|
|
260
|
+
+ ' от прогона к прогону): ' + cliCommand('--init')
|
|
261
|
+
];
|
|
262
|
+
}
|
package/src/refusal.js
CHANGED
|
@@ -108,7 +108,7 @@ export const USAGE = [
|
|
|
108
108
|
' hook-run то, что зовёт хук: пересборка и коммит отчёта (вручную не нужно)',
|
|
109
109
|
'',
|
|
110
110
|
'Режимы:',
|
|
111
|
-
' --init [файл]
|
|
111
|
+
' --init [файл] закрепить настройки файлом (--force — перезаписать существующий)',
|
|
112
112
|
' --write собрать таблицу в файл из настроек',
|
|
113
113
|
' --data данные контракта в stdout — для страницы и для агента',
|
|
114
114
|
' --page [файл] страница отчёта (по умолчанию рядом с таблицей)',
|
|
@@ -124,6 +124,11 @@ export const USAGE = [
|
|
|
124
124
|
'Запуск один: команда и режим не совмещаются, режим тоже один, и лишнее слово',
|
|
125
125
|
'вместе с незнакомым ключом — отказ с готовой командой, а не обычный прогон.',
|
|
126
126
|
'',
|
|
127
|
+
'Настройки заводить не обязательно: без файла они выводятся из самого проекта',
|
|
128
|
+
'(колонки — по группам путей из дерева и истории, журнал и куда писать — оттуда же),',
|
|
129
|
+
'и об этом сказано в выводе. «--init» закрепляет выведенное файлом — дальше правят его;',
|
|
130
|
+
'файл, названный ключом «--config», обязан быть, иначе — отказ.',
|
|
131
|
+
'',
|
|
127
132
|
'Коды выхода: 0 — всё хорошо, 1 — расхождение с историей или неполнота, 2 — вызов,',
|
|
128
133
|
'настройки или окружение, 3 — неполная история, 4 — нет датчика, 5 — внутренняя',
|
|
129
134
|
'ошибка. У doctor свой порядок: 2, 3, 1, 4 — по важности находки, а не по тому,',
|
package/src/render.js
CHANGED
|
@@ -65,13 +65,13 @@ export function noteText(rows, cfg) {
|
|
|
65
65
|
+ fill(loc.note.rows, { rows: rows.length, command: cfg.fixCommand });
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
/* Шапка таблицы: строка групп (итог и колонки) и под ней строка метрик. */
|
|
69
|
+
function tableHead(cfg, loc) {
|
|
70
70
|
const metrics = cfg.metrics;
|
|
71
71
|
const groupHead = (label, cls) => '<th colspan="' + metrics.length + '" class="' + cls + '">' + esc(label) + '</th>';
|
|
72
72
|
const subHead = () => metrics.map((m, i) => '<th' + (i === 0 ? ' class="g"' : '') + '>'
|
|
73
73
|
+ esc(METRICS[m].label) + '</th>').join('');
|
|
74
|
-
|
|
74
|
+
return '<tr>'
|
|
75
75
|
+ '<th rowspan="2" class="c-commit">' + esc(loc.commit) + '</th>'
|
|
76
76
|
+ groupHead(loc.total, 'g')
|
|
77
77
|
+ cfg.columns.map((c) => groupHead(c.label, 'g')).join('')
|
|
@@ -79,11 +79,17 @@ export function render(rows, cfg) {
|
|
|
79
79
|
+ subHead()
|
|
80
80
|
+ cfg.columns.map(() => subHead()).join('')
|
|
81
81
|
+ '</tr>';
|
|
82
|
+
}
|
|
82
83
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
/* Тело таблицы: строки-коммиты сверху вниз — от старых к новым, — а над ними
|
|
85
|
+
* строка «сейчас» с абсолютными размерами.
|
|
86
|
+
*
|
|
87
|
+
* Дельта считается к предыдущему коммиту (в списке ниже он идёт строкой ниже),
|
|
88
|
+
* а появление файла — рост на весь его объём: иначе сумма дельт по колонке не
|
|
89
|
+
* сходилась бы с текущим размером, и верхняя строка была бы недоказуемой. Всё
|
|
90
|
+
* это считает `rowModel` — тот же, что и на странице. */
|
|
91
|
+
function tableBody(rows, cfg, loc) {
|
|
92
|
+
const metrics = cfg.metrics;
|
|
87
93
|
const cellsHtml = (make) => (cells) => cells.map((c, mi) => make(c, mi === 0)).join('');
|
|
88
94
|
const rowCells = cellsHtml((c, first) => cellHtml(c, first));
|
|
89
95
|
const nowCells = cellsHtml((v, first) => valueHtml(v, first));
|
|
@@ -94,12 +100,15 @@ export function render(rows, cfg) {
|
|
|
94
100
|
+ blocksHtml(rowModel(row.cells, prev === null ? null : prev.cells, metrics), rowCells)
|
|
95
101
|
+ '</tr>';
|
|
96
102
|
}).reverse().join('\n');
|
|
97
|
-
|
|
98
103
|
const nowRow = rows.length === 0 ? '' : '<tr class="now">'
|
|
99
104
|
+ '<th class="c-commit">' + esc(loc.now) + '</th>'
|
|
100
105
|
+ blocksHtml(nowModel(rows[rows.length - 1].cells, metrics), nowCells)
|
|
101
106
|
+ '</tr>';
|
|
107
|
+
return nowRow + '\n' + body;
|
|
108
|
+
}
|
|
102
109
|
|
|
110
|
+
export function render(rows, cfg) {
|
|
111
|
+
const loc = LOCALES[cfg.locale];
|
|
103
112
|
/* Подпись называет только то, что не меняется от самих служебных коммитов:
|
|
104
113
|
* число строк и список колонок. Иначе таблица считалась бы устаревшей сразу
|
|
105
114
|
* после собственного коммита — из-за пересчитанного «пропущено N» в тексте. */
|
|
@@ -118,11 +127,10 @@ ${CSS}
|
|
|
118
127
|
<p class="note">${noteText(rows, cfg)}</p>
|
|
119
128
|
<table>
|
|
120
129
|
<thead>
|
|
121
|
-
${
|
|
130
|
+
${tableHead(cfg, loc)}
|
|
122
131
|
</thead>
|
|
123
132
|
<tbody>
|
|
124
|
-
${
|
|
125
|
-
${body}
|
|
133
|
+
${tableBody(rows, cfg, loc)}
|
|
126
134
|
</tbody>
|
|
127
135
|
</table>
|
|
128
136
|
</body>
|
package/src/size-table.js
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
* size --json строки как JSON в stdout
|
|
45
45
|
* size --data данные для страницы и агента в stdout
|
|
46
46
|
* size --page [файл] собрать страницу отчёта
|
|
47
|
-
* size --init [файл]
|
|
47
|
+
* size --init [файл] закрепить настройки файлом (без него они выводятся из проекта)
|
|
48
48
|
* size --config <путь> другой файл настроек
|
|
49
49
|
* size --help справка и коды выхода
|
|
50
50
|
*
|
|
@@ -60,17 +60,23 @@
|
|
|
60
60
|
* strip → refusal, parse — снятие балласта и гард;
|
|
61
61
|
* metrics → strip — реестр метрик;
|
|
62
62
|
* git → refusal — всё, что читается у git;
|
|
63
|
-
*
|
|
63
|
+
* project → git, refusal, data — что проект говорит о себе сам;
|
|
64
|
+
* config → project, git, refusal, locales, metrics, data — настройки проекта;
|
|
64
65
|
* history → git, metrics, journal, refusal — сборка по истории;
|
|
65
66
|
* data → locales, metrics, journal, history, tool — контракт со страницей;
|
|
66
67
|
* render → locales, metrics, journal, derived — статический артефакт;
|
|
67
68
|
* page/build → locales, render — страница отчёта;
|
|
68
|
-
*
|
|
69
|
+
* modes → почти все — что делать по запросу;
|
|
70
|
+
* init → config, project, refusal, artifact — закрепление настроек файлом;
|
|
71
|
+
* cli → args, modes, init, config, refusal — вход: разбор и доставка.
|
|
69
72
|
*
|
|
70
73
|
* Публичный API — то, чем пользуются `bin/size.js` и `test/`: список ниже не
|
|
71
74
|
* сокращается при разбиении (это проверяет `test/api.test.js`).
|
|
72
75
|
*/
|
|
73
|
-
export { main
|
|
76
|
+
export { main } from './cli.js';
|
|
77
|
+
export { initMode } from './init.js';
|
|
78
|
+
export { sniffColumns } from './project.js';
|
|
79
|
+
export { check, dataMode, pageMode } from './modes.js';
|
|
74
80
|
export { reportData, categoryOf, CATEGORY_EXTS, CATEGORY_ORDER } from './data.js';
|
|
75
81
|
export { measureHistory } from './history.js';
|
|
76
82
|
export { render, noteText, cellHtml, valueHtml } from './render.js';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { stripJs } from './js.js';
|
|
2
|
+
|
|
3
|
+
/* Формы текста, у которых снятие балласта своё: разметка, стили, строки файла и
|
|
4
|
+
* JSON. Разные формы — разные правила, и одно правило на все было бы либо
|
|
5
|
+
* трусостью (не снимать ничего), либо порчей чужого синтаксиса. */
|
|
6
|
+
|
|
7
|
+
export function stripCss(src) {
|
|
8
|
+
return src.replace(/\/\*[\s\S]*?\*\//g, ' ');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* HTML: комментарии разметки (включая маркеры вклеек `<!--icon …-->` и
|
|
12
|
+
* `<!--/icon-->`), комментарии внутри <script> как JS и внутри <style> как CSS. */
|
|
13
|
+
export function stripHtml(src) {
|
|
14
|
+
return src
|
|
15
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
16
|
+
.replace(/(<script\b[^>]*>)([\s\S]*?)(<\/script>)/gi, (_m, open, body, close) => open + stripJs(body) + close)
|
|
17
|
+
.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_m, open, body, close) => open + stripCss(body) + close);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function stripLines(text) {
|
|
21
|
+
return text.split('\n').map((l) => l.trim()).filter((l) => l !== '').join('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function compactJson(text) {
|
|
25
|
+
try { return JSON.stringify(JSON.parse(text)); } catch (_e) { return stripLines(text); }
|
|
26
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import vm from 'vm';
|
|
3
|
+
import { refuseCause } from '../refusal.js';
|
|
4
|
+
import { moduleError } from '../parse.js';
|
|
5
|
+
|
|
6
|
+
/* Гард стриппера: он не имеет права выбросить что-то кроме комментариев и
|
|
7
|
+
* отступов, поэтому результат обязан компилироваться. Проверяем только те
|
|
8
|
+
* расширения, где содержимое — валидный JavaScript (список в конфиге,
|
|
9
|
+
* `minify.guard`): TypeScript или JSX хостом не проверяются, и делать вид, что
|
|
10
|
+
* проверили, было бы хуже, чем не проверять.
|
|
11
|
+
*
|
|
12
|
+
* Модуль или скрипт решает текст, а не расширение: проект с бандлером пишет
|
|
13
|
+
* `import`/`export` прямо в `.js` (и с `type: module` в манифесте, и без него), а
|
|
14
|
+
* `vm.Script` разбирает такой файл как скрипт и падает на самом `export`. Гард
|
|
15
|
+
* обязан понимать оба формата, поэтому пробует тот, на который файл похож, и
|
|
16
|
+
* принимает результат, если он разбирается хотя бы одним из двух способов.
|
|
17
|
+
* От этого он не слабеет: настоящая поломка не разберётся ни скриптом, ни
|
|
18
|
+
* модулем, и тогда наружу идёт причина того разбора, которым файл был.
|
|
19
|
+
*
|
|
20
|
+
* Модуль разбирает отдельный рабочий поток (`parse.js`): без него разбор модуля
|
|
21
|
+
* стоил бы запуска Node на каждую клетку. Иначе конфиг вида `eslint.config.mjs`
|
|
22
|
+
* остался бы без гарда, а без гарда его правка могла бы испортить «объём» молча.
|
|
23
|
+
*
|
|
24
|
+
* Когда не разбирается даже исходный текст, стриппер тут ни при чём: в этой
|
|
25
|
+
* графе измеряется не JavaScript (TypeScript, JSX), и это отказ с командой
|
|
26
|
+
* починки — правкой настроек. */
|
|
27
|
+
|
|
28
|
+
const MODULE_MARK = /^[ \t]*(?:import|export)\b/m;
|
|
29
|
+
const MODULE_EXT = ['.mjs'];
|
|
30
|
+
|
|
31
|
+
export function assertCompilable(min, rev, p, src) {
|
|
32
|
+
// Скрипт пробуется первым не ради формы, а ради цены: этот разбор идёт
|
|
33
|
+
// в процессе, а модуль — в рабочем потоке.
|
|
34
|
+
const asScript = scriptError(min, p);
|
|
35
|
+
if (asScript === null) return;
|
|
36
|
+
const asModule = moduleError(min);
|
|
37
|
+
if (asModule === null) return;
|
|
38
|
+
const shape = MODULE_EXT.indexOf(path.extname(p).toLowerCase()) >= 0 || MODULE_MARK.test(min);
|
|
39
|
+
if (src !== undefined && scriptError(src, p) !== null && moduleError(src) !== null) {
|
|
40
|
+
refuseCause('файл не JavaScript', 'файл ' + p + ' — не JavaScript: его исходный текст не'
|
|
41
|
+
+ ' разбирается ни как скрипт, ни как модуль, так что дело не в стриптере, а '
|
|
42
|
+
+ path.extname(p) + ' стоит в minify.guard: ' + (shape ? asModule : asScript) + '\n'
|
|
43
|
+
+ ' починка: уберите это расширение из minify.guard или задайте для него '
|
|
44
|
+
+ 'minify.ext — например { "' + path.extname(p).toLowerCase() + '": "strip-lines" }');
|
|
45
|
+
}
|
|
46
|
+
// Причина — того разбора, которым файл был: обвинять в чужой форме незачем.
|
|
47
|
+
throw new Error('стриппер испортил ' + p + ' на ' + rev.slice(0, 7) + ': '
|
|
48
|
+
+ (shape ? asModule : asScript));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Разбор как скрипт — в процессе: дешевле и без временных файлов.
|
|
52
|
+
function scriptError(text, p) {
|
|
53
|
+
try {
|
|
54
|
+
new vm.Script(text, { filename: p });
|
|
55
|
+
return null;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return e.message;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/strip/js.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/* Снятие комментариев и отступов — метрика «объём без балласта», а не
|
|
2
|
+
* минификация: пробелы внутри строк и порядок токенов не трогаются (это позволит
|
|
3
|
+
* сравнивать числа между языками и не зависит от чужого инструмента, которого в
|
|
4
|
+
* проекте нет). Строки и шаблоны проходят насквозь, блочный комментарий
|
|
5
|
+
* заменяется пробелом, чтобы `a` и `b` из `a` + блочный комментарий + `b` не
|
|
6
|
+
* склеились в одно имя, перевод строки после `//` сохраняется — он разделяет
|
|
7
|
+
* токены.
|
|
8
|
+
*
|
|
9
|
+
* Проход разложен по случаям: комментарий, регексп, строка и обычный символ.
|
|
10
|
+
* Случай говорит, сколько он съел (`false` — не его), и все они делят одно
|
|
11
|
+
* состояние прохода; состояния «по копии на случай» здесь не заводится, потому
|
|
12
|
+
* что решение о регекспе зависит от всего, что уже выведено.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/* Состояние прохода: текст, место в нём и то, чем отличают регексп от деления, —
|
|
16
|
+
* последний значимый символ вывода и хвост последнего слова (после `return` идёт
|
|
17
|
+
* выражение, а не деление). */
|
|
18
|
+
function scanOf(src) {
|
|
19
|
+
return { src: src, out: '', i: 0, last: '', word: '' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function stripJs(src) {
|
|
23
|
+
const s = scanOf(src);
|
|
24
|
+
while (s.i < src.length) {
|
|
25
|
+
/* Комментарные пары проверяются до регекси: ни `/`, ни `*` не могут быть
|
|
26
|
+
* первым символом литерала регекспа, а вот `/*` в начале файла — обычное дело. */
|
|
27
|
+
if (skipLineComment(s) || skipBlockComment(s) || takeRegex(s) || takeString(s)) continue;
|
|
28
|
+
putChar(s);
|
|
29
|
+
}
|
|
30
|
+
return s.out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Строчный комментарий: `//` съедается до перевода строки, сам перевод остаётся.
|
|
34
|
+
function skipLineComment(s) {
|
|
35
|
+
if (s.src[s.i] !== '/' || s.src[s.i + 1] !== '/') return false;
|
|
36
|
+
const nl = s.src.indexOf('\n', s.i);
|
|
37
|
+
s.i = nl === -1 ? s.src.length : nl;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Блочный комментарий — на пробел: он разделяет имена, но не занимает объём.
|
|
42
|
+
function skipBlockComment(s) {
|
|
43
|
+
if (s.src[s.i] !== '/' || s.src[s.i + 1] !== '*') return false;
|
|
44
|
+
const end = s.src.indexOf('*/', s.i + 2);
|
|
45
|
+
s.out += ' ';
|
|
46
|
+
s.i = end === -1 ? s.src.length : end + 2;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Регексп начинается там, где ожидается операнд: после оператора, открывающей
|
|
51
|
+
* скобки или ключевого слова. Признак грубый, но его хватает: без него
|
|
52
|
+
* `replace(/\//g, …)` читалось бы как начало строчного комментария и резало
|
|
53
|
+
* строку (проверено гардом компиляции). */
|
|
54
|
+
const REGEX_KEYWORDS = ['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'case', 'do', 'else', 'yield', 'await'];
|
|
55
|
+
|
|
56
|
+
function regexAllowed(last, word) {
|
|
57
|
+
if (last === '') return true;
|
|
58
|
+
if (REGEX_KEYWORDS.indexOf(word) !== -1) return true;
|
|
59
|
+
return '([{,;:=!&|?+-*%~^<>'.indexOf(last) !== -1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function takeRegex(s) {
|
|
63
|
+
if (s.src[s.i] !== '/' || !regexAllowed(s.last, s.word)) return false;
|
|
64
|
+
const end = endOfRegex(s.src, s.i);
|
|
65
|
+
s.out += s.src.slice(s.i, end);
|
|
66
|
+
s.i = end;
|
|
67
|
+
s.last = '/';
|
|
68
|
+
s.word = '';
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function endOfRegex(src, start) {
|
|
73
|
+
let i = start + 1;
|
|
74
|
+
let inClass = false;
|
|
75
|
+
while (i < src.length) {
|
|
76
|
+
const ch = src[i];
|
|
77
|
+
if (ch === '\\') { i += 2; continue; }
|
|
78
|
+
if (ch === '\n') return start + 1; // наткнулись на строку — значит, это был не регексп
|
|
79
|
+
if (ch === '[') inClass = true;
|
|
80
|
+
else if (ch === ']') inClass = false;
|
|
81
|
+
else if (ch === '/' && !inClass) return i + 1;
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
return start + 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Строка или шаблон — целиком, вместе со своим содержимым.
|
|
88
|
+
function takeString(s) {
|
|
89
|
+
const quote = s.src[s.i];
|
|
90
|
+
if (quote !== '"' && quote !== "'" && quote !== '`') return false;
|
|
91
|
+
const end = endOfString(s.src, s.i, quote);
|
|
92
|
+
s.out += s.src.slice(s.i, end);
|
|
93
|
+
s.i = end;
|
|
94
|
+
s.last = quote;
|
|
95
|
+
s.word = '';
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function endOfString(src, start, quote) {
|
|
100
|
+
let i = start + 1;
|
|
101
|
+
while (i < src.length) {
|
|
102
|
+
const ch = src[i];
|
|
103
|
+
if (ch === '\\') { i += 2; continue; }
|
|
104
|
+
if (ch === quote) return i + 1;
|
|
105
|
+
// В шаблоне `${…}` живёт выражение, а в нём — свои строки.
|
|
106
|
+
if (quote === '`' && ch === '$' && src[i + 1] === '{') { i = endOfTemplateExpr(src, i + 2); continue; }
|
|
107
|
+
i++;
|
|
108
|
+
}
|
|
109
|
+
return src.length;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function endOfTemplateExpr(src, start) {
|
|
113
|
+
let depth = 1;
|
|
114
|
+
let i = start;
|
|
115
|
+
while (i < src.length) {
|
|
116
|
+
const ch = src[i];
|
|
117
|
+
if (ch === '\\') { i += 2; continue; }
|
|
118
|
+
if (ch === '{') { depth++; i++; continue; }
|
|
119
|
+
if (ch === '}') { depth--; i++; if (depth === 0) return i; continue; }
|
|
120
|
+
if (ch === '"' || ch === "'" || ch === '`') { i = endOfString(src, i, ch); continue; }
|
|
121
|
+
i++;
|
|
122
|
+
}
|
|
123
|
+
return src.length;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* Обычный символ: он попадает в вывод, а состояние прохода запоминает по нему,
|
|
127
|
+
* где может стоять регексп. */
|
|
128
|
+
function putChar(s) {
|
|
129
|
+
const ch = s.src[s.i];
|
|
130
|
+
s.out += ch;
|
|
131
|
+
if (ch.trim() !== '') {
|
|
132
|
+
s.last = ch;
|
|
133
|
+
s.word = /[\w$]/.test(ch) ? s.word + ch : '';
|
|
134
|
+
}
|
|
135
|
+
s.i++;
|
|
136
|
+
}
|