@vernikr/size-report 1.1.1
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 +241 -0
- package/LICENSE +21 -0
- package/README.md +892 -0
- package/bin/size.js +6 -0
- package/package.json +61 -0
- package/src/artifact.css +8 -0
- package/src/artifact.js +21 -0
- package/src/check.js +142 -0
- package/src/cli.js +476 -0
- package/src/config.js +158 -0
- package/src/css.js +37 -0
- package/src/data.js +105 -0
- package/src/derived.js +115 -0
- package/src/doctor.js +225 -0
- package/src/explain.js +113 -0
- package/src/git.js +221 -0
- package/src/history.js +206 -0
- package/src/hook.js +404 -0
- package/src/journal.js +66 -0
- package/src/locales.js +124 -0
- package/src/metrics.js +268 -0
- package/src/minify.js +67 -0
- package/src/optional.js +31 -0
- package/src/page/app.css +165 -0
- package/src/page/app.js +560 -0
- package/src/page/build.js +102 -0
- package/src/parse-worker.js +34 -0
- package/src/parse.js +136 -0
- package/src/refusal.js +133 -0
- package/src/render.js +131 -0
- package/src/size-table.js +86 -0
- package/src/strip.js +231 -0
- package/src/table.css +35 -0
- package/src/tokens.js +76 -0
- package/src/tool.js +24 -0
- package/templates/README.md +79 -0
- package/templates/ci.yml +67 -0
- package/templates/size-report.config.json +53 -0
package/src/metrics.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import zlib from 'zlib';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { EXACT_STRATEGIES, assertCompilable, byteLen, minifyForm, strategyFor } from './strip.js';
|
|
4
|
+
import { MINIFY_LOADERS, minifier, minifyWithEsbuild } from './minify.js';
|
|
5
|
+
import { CHARS_PER_TOKEN, isBinary, tokenCount, tokenizer } from './tokens.js';
|
|
6
|
+
|
|
7
|
+
/* Реестр метрик: что измеряется, нужен ли метрике текст и насколько честна цифра.
|
|
8
|
+
* Отдельно от способов снятия балласта: метрика — это обещание про число, а не
|
|
9
|
+
* способ его получить.
|
|
10
|
+
*
|
|
11
|
+
* Описание метрики для читателя берётся не из полей реестра, а из `metricView`:
|
|
12
|
+
* у одной и той же метрики оно зависит от настроек (`min` — это настоящее сжатие
|
|
13
|
+
* или упрощение, `tok` — точный словарь или оценка), и разойтись двум ответам на
|
|
14
|
+
* один вопрос негде. */
|
|
15
|
+
|
|
16
|
+
/* Способы получить метрику `min`: снятие балласта и настоящее сжатие. Механизм у
|
|
17
|
+
* них разный, и обещание тоже, поэтому у каждого свой способ, своя честность и своё
|
|
18
|
+
* примечание — и никто из них не выдаётся за другого. */
|
|
19
|
+
export const MINIFY_ENGINES = ['strip', 'esbuild'];
|
|
20
|
+
|
|
21
|
+
const STYLES = {
|
|
22
|
+
strip: {
|
|
23
|
+
note: { ru: 'та же форма без комментариев и отступов', en: 'the same form without comments and indentation' },
|
|
24
|
+
method: {
|
|
25
|
+
ru: 'снятие комментариев и отступов (не минификация: имена не сокращаются)',
|
|
26
|
+
en: 'comments and indentation stripped (not minification: names are not shortened)'
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
esbuild: {
|
|
30
|
+
note: {
|
|
31
|
+
ru: 'настоящая минификация: имена сокращены, пробелы убраны',
|
|
32
|
+
en: 'real minification: names shortened, whitespace removed'
|
|
33
|
+
},
|
|
34
|
+
method: { ru: 'esbuild {version} (minify, rename)', en: 'esbuild {version} (minify, rename)' },
|
|
35
|
+
fallback: {
|
|
36
|
+
ru: '; остальные форматы ({exts}) — упрощение без комментариев и отступов, то есть приближение',
|
|
37
|
+
en: '; other formats ({exts}) lose comments and indentation — an approximation'
|
|
38
|
+
},
|
|
39
|
+
unavailable: {
|
|
40
|
+
ru: ' (минификатор esbuild недоступен — счёт упрощением, то есть приближением)',
|
|
41
|
+
en: ' (the esbuild minifier is unavailable — comments and indentation are stripped: an approximation)'
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
tok: {
|
|
45
|
+
note: {
|
|
46
|
+
ru: 'вес для языковой модели: на сколько единиц текста (токенов) он разбирается',
|
|
47
|
+
en: 'weight for a language model: how many text units (tokens) it splits into'
|
|
48
|
+
},
|
|
49
|
+
method: { ru: '{tool} {version}, {encoding} (BPE)', en: '{tool} {version}, {encoding} (BPE)' },
|
|
50
|
+
binary: {
|
|
51
|
+
ru: '; для бинарных форматов ({exts}) это счёт байтов, а не текста — приближение',
|
|
52
|
+
en: '; binary formats ({exts}) are counted by bytes rather than text — an approximation'
|
|
53
|
+
},
|
|
54
|
+
estimate: {
|
|
55
|
+
ru: 'оценка по длине: 1 токен ≈ {chars} знака ({encoding} недоступен) — приближение',
|
|
56
|
+
en: 'length-based estimate: 1 token ≈ {chars} characters ({encoding} is unavailable) — an approximation'
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/* Метрика объявляет, нужен ли ей текст блоба: `needsText: false` вместе с
|
|
62
|
+
* `fromSize: true` означает «хватит размера объекта», и тогда содержимое не
|
|
63
|
+
* читается вовсе (`git cat-file --batch` не вызывается). */
|
|
64
|
+
export const METRICS = {
|
|
65
|
+
raw: {
|
|
66
|
+
label: 'raw',
|
|
67
|
+
needsText: false,
|
|
68
|
+
fromSize: true,
|
|
69
|
+
note: { ru: 'файл как он есть', en: 'the file as it is' },
|
|
70
|
+
method: {
|
|
71
|
+
ru: 'размер объекта git',
|
|
72
|
+
en: 'the size of the git object'
|
|
73
|
+
},
|
|
74
|
+
accuracy: 'exact',
|
|
75
|
+
measure: (text) => byteLen(text)
|
|
76
|
+
},
|
|
77
|
+
min: {
|
|
78
|
+
label: 'min',
|
|
79
|
+
needsText: true,
|
|
80
|
+
view: minView,
|
|
81
|
+
measure: (text, file, cfg, rev) => {
|
|
82
|
+
if (esbuildLoader(file, cfg) !== null) return byteLen(minifyWithEsbuild(text, file, rev));
|
|
83
|
+
const min = minifyForm(text, file, cfg);
|
|
84
|
+
const ext = path.extname(file).toLowerCase();
|
|
85
|
+
/* Гард стриппера стережёт упрощение, а не минификатор: минификатор разбирает
|
|
86
|
+
* файл сам и о своей неудаче говорит отказом (`src/minify.js`), а этот гард
|
|
87
|
+
* отвечает на вопрос, не выбросило ли наше снятие балласта чего-нибудь, кроме
|
|
88
|
+
* комментариев и отступов. */
|
|
89
|
+
if (strategyFor(file, cfg) === 'strip-js' && cfg.minify.guard.indexOf(ext) >= 0) {
|
|
90
|
+
assertCompilable(min, rev, file, text);
|
|
91
|
+
}
|
|
92
|
+
return byteLen(min);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
tok: {
|
|
96
|
+
label: 'tok',
|
|
97
|
+
needsText: true,
|
|
98
|
+
view: tokView,
|
|
99
|
+
measure: (text, _file, cfg) => tokenCount(text, cfg.tokens)
|
|
100
|
+
},
|
|
101
|
+
gzip: {
|
|
102
|
+
label: 'gzip',
|
|
103
|
+
needsText: true,
|
|
104
|
+
note: { ru: 'сжатый поток (zlib, уровень 9)', en: 'compressed stream (zlib, level 9)' },
|
|
105
|
+
method: { ru: 'zlib, уровень 9', en: 'zlib, level 9' },
|
|
106
|
+
accuracy: 'exact',
|
|
107
|
+
measure: (text) => zlib.gzipSync(Buffer.from(text, 'utf8'), { level: 9 }).length
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/* Описание метрики для читателя: `note` — что означает число, `method` — чем оно
|
|
112
|
+
* получено, `accuracy` — точное оно или приближённое. */
|
|
113
|
+
export function metricView(name, cfg) {
|
|
114
|
+
const metric = METRICS[name];
|
|
115
|
+
if (metric.view !== undefined) return metric.view(cfg);
|
|
116
|
+
return {
|
|
117
|
+
label: metric.label,
|
|
118
|
+
note: metric.note[cfg.locale],
|
|
119
|
+
method: metric.method[cfg.locale],
|
|
120
|
+
accuracy: metric.accuracy
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* Подпись метрики `min`. Соглашение о честности: `accuracy` говорит про худшее в
|
|
125
|
+
* колонке, а способ называет, где именно приближение, — поэтому один формат без
|
|
126
|
+
* минификатора делает метрику приближённой целиком, а не прячется за «exact»
|
|
127
|
+
* соседнего файла. Худшее берётся по тому же правилу, что и пометки клеток
|
|
128
|
+
* (`pointExact`), а не по названию способа: отчёт, где нет ни одного
|
|
129
|
+
* приближённого формата, точен и со снятым балластом. */
|
|
130
|
+
function minView(cfg) {
|
|
131
|
+
const loc = cfg.locale;
|
|
132
|
+
const rough = approximateFormats('min', cfg);
|
|
133
|
+
if (minEngine(cfg) === 'esbuild') {
|
|
134
|
+
let method = STYLES.esbuild.method[loc].replace('{version}', minifier().version);
|
|
135
|
+
if (rough.length > 0) method += STYLES.esbuild.fallback[loc].replace('{exts}', rough.join(' '));
|
|
136
|
+
return {
|
|
137
|
+
label: METRICS.min.label,
|
|
138
|
+
note: STYLES.esbuild.note[loc],
|
|
139
|
+
method: method,
|
|
140
|
+
accuracy: rough.length === 0 ? 'exact' : 'approximate'
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const degraded = cfg.minify.engine === 'esbuild';
|
|
144
|
+
return {
|
|
145
|
+
label: METRICS.min.label,
|
|
146
|
+
note: STYLES.strip.note[loc],
|
|
147
|
+
method: STYLES.strip.method[loc] + (degraded ? STYLES.esbuild.unavailable[loc] : ''),
|
|
148
|
+
accuracy: rough.length === 0 ? 'exact' : 'approximate'
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* Подпись метрики `tok`. Соглашение о честности то же, что у `min`: способ говорит,
|
|
153
|
+
* каким словарём снято число (семейство и кодировка — часть счёта, а не подробность),
|
|
154
|
+
* а `accuracy` — точное оно или приближённое. Приближённым оно становится в двух
|
|
155
|
+
* случаях, и оба названы словами: форматы, для которых токены не считаются (у них
|
|
156
|
+
* число идёт по байтам), и отсутствие словаря (тогда счёт идёт оценкой по длине). */
|
|
157
|
+
function tokView(cfg) {
|
|
158
|
+
const loc = cfg.locale;
|
|
159
|
+
const settings = cfg.tokens;
|
|
160
|
+
const { tool, version } = tokenizer(settings);
|
|
161
|
+
if (tool === null) {
|
|
162
|
+
return {
|
|
163
|
+
label: METRICS.tok.label,
|
|
164
|
+
note: STYLES.tok.note[loc],
|
|
165
|
+
method: STYLES.tok.estimate[loc]
|
|
166
|
+
.replace('{chars}', CHARS_PER_TOKEN).replace('{encoding}', settings.encoding),
|
|
167
|
+
accuracy: 'approximate'
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const binary = approximateFormats('tok', cfg);
|
|
171
|
+
let method = STYLES.tok.method[loc]
|
|
172
|
+
.replace('{tool}', 'gpt-tokenizer').replace('{version}', version).replace('{encoding}', settings.encoding);
|
|
173
|
+
if (binary.length > 0) method += STYLES.tok.binary[loc].replace('{exts}', binary.join(' '));
|
|
174
|
+
return {
|
|
175
|
+
label: METRICS.tok.label,
|
|
176
|
+
note: STYLES.tok.note[loc],
|
|
177
|
+
method: method,
|
|
178
|
+
accuracy: binary.length === 0 ? 'exact' : 'approximate'
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* Точное ли число у конкретной клетки — одно правило и для подписи метрики, и для
|
|
183
|
+
* пометки клетки. Поэтому подпись не может разойтись с клетками, а список
|
|
184
|
+
* приближённых форматов считается здесь же, по тому же правилу.
|
|
185
|
+
*
|
|
186
|
+
* `min` точен там, где файл действительно минифицируется: минификатором или
|
|
187
|
+
* разбором формата, который короче уже не станет (JSON теряет только незначащие
|
|
188
|
+
* пробелы — список точных стратегий ведёт `strip.js`, потому что стратегии живут
|
|
189
|
+
* там). `tok` точен там, где есть словарь и формат текстовый: «токены» картинки
|
|
190
|
+
* или шрифта — это её байты. `raw` и `gzip` точны всегда: это однозначные
|
|
191
|
+
* величины. */
|
|
192
|
+
export function pointExact(name, file, cfg) {
|
|
193
|
+
if (name === 'min') return minifiedForm(file, cfg);
|
|
194
|
+
if (name === 'tok') return tokenizer(cfg.tokens).tool !== null && !isBinary(file);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* Форматы этого отчёта, которые будут измерены приближённо. Список выводится из
|
|
199
|
+
* настроек и правила точности, а не пишется руками. */
|
|
200
|
+
function approximateFormats(name, cfg) {
|
|
201
|
+
const exts = [];
|
|
202
|
+
cfg.columns.forEach((col) => {
|
|
203
|
+
col.paths.forEach((p) => {
|
|
204
|
+
if (pointExact(name, p, cfg)) return;
|
|
205
|
+
const ext = path.extname(p).toLowerCase();
|
|
206
|
+
if (exts.indexOf(ext) < 0) exts.push(ext);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
return exts.sort();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function minifiedForm(file, cfg) {
|
|
213
|
+
return esbuildLoader(file, cfg) !== null || EXACT_STRATEGIES.indexOf(strategyFor(file, cfg)) >= 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* Идёт ли файл в минификатор: сжатие запрошено, доступно и не отменено явным
|
|
217
|
+
* выбором проекта — `minify.ext` старше движка и служит выходом, если расширение
|
|
218
|
+
* соврало о содержимом. Ответ один на два вопроса: как считать и что обещать. */
|
|
219
|
+
function esbuildLoader(file, cfg) {
|
|
220
|
+
if (minEngine(cfg) !== 'esbuild') return null;
|
|
221
|
+
const ext = path.extname(file).toLowerCase();
|
|
222
|
+
if (cfg.minify.ext[ext] !== undefined) return null;
|
|
223
|
+
return MINIFY_LOADERS[ext] === undefined ? null : MINIFY_LOADERS[ext];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* Действующий способ: запрошенный может быть недоступен — тогда метрика отступает
|
|
227
|
+
* к другому счёту, а отступление объявляется наружу (`sensorGaps`), иначе
|
|
228
|
+
* приближение ушло бы как точное число. */
|
|
229
|
+
export function minEngine(cfg) {
|
|
230
|
+
if (cfg.minify.engine !== 'esbuild') return 'strip';
|
|
231
|
+
return minifier().tool === null ? 'strip' : 'esbuild';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* Чего не хватает для того, что просили: причина и починка для человека, по одной
|
|
235
|
+
* на датчик. Причина загрузчика уходит только сюда — в подписи метрики она была бы
|
|
236
|
+
* машинной строкой (путём чужого `node_modules`), от которой вывод перестал бы
|
|
237
|
+
* быть одинаковым на разных машинах, а в подписи отчёта — понятным. */
|
|
238
|
+
export function sensorGaps(cfg) {
|
|
239
|
+
const gaps = [];
|
|
240
|
+
const minify = minifier();
|
|
241
|
+
if (cfg.minify.engine === 'esbuild' && minify.tool === null) {
|
|
242
|
+
gaps.push({
|
|
243
|
+
why: 'метрика «min» считает упрощением: минификатор недоступен — ' + minify.why,
|
|
244
|
+
fix: 'поставьте необязательные зависимости заново или задайте "minify": {"engine": "strip"}'
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (cfg.metrics.indexOf('tok') >= 0) {
|
|
248
|
+
const tokens = tokenizer(cfg.tokens);
|
|
249
|
+
if (tokens.tool === null) {
|
|
250
|
+
gaps.push({
|
|
251
|
+
why: 'метрика «tok» считает оценкой по длине: словаря нет — ' + tokens.why,
|
|
252
|
+
fix: 'поставьте необязательные зависимости заново или уберите "tok" из metrics'
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return gaps;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* Одно место, где решается, читать метрику из размера объекта или из текста:
|
|
260
|
+
* метрика без текста на недогруженном блобе — ошибка, а не молчаливый ноль. */
|
|
261
|
+
export function measureBlob(name, blob, file, cfg, rev) {
|
|
262
|
+
const metric = METRICS[name];
|
|
263
|
+
if (metric.fromSize) return blob.size;
|
|
264
|
+
if (blob.text === null) {
|
|
265
|
+
throw new Error('метрике «' + name + '» нужно содержимое ' + file + ' на ' + rev.slice(0, 7) + ', а оно не прочитано');
|
|
266
|
+
}
|
|
267
|
+
return metric.measure(blob.text, file, cfg, rev);
|
|
268
|
+
}
|
package/src/minify.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { loadOptional } from './optional.js';
|
|
3
|
+
import { refuseCause } from './refusal.js';
|
|
4
|
+
|
|
5
|
+
/* Настоящий минификатор — необязательная зависимость (её устройство — в
|
|
6
|
+
* `src/optional.js`), а здесь только то, что знает сам минификатор: какие форматы
|
|
7
|
+
* он берёт и как считается отказ.
|
|
8
|
+
*
|
|
9
|
+
* Отказ минификатора (файл не разобрался) исключением быть обязан: расширение
|
|
10
|
+
* соврало о содержимом, и упрощение вместо сжатия подменило бы число молча. */
|
|
11
|
+
|
|
12
|
+
/* Расширения, за которые отвечает минификатор. Таблица — единственный источник
|
|
13
|
+
* правды и для замера, и для подписи метрики («остальные форматы — приближение»),
|
|
14
|
+
* поэтому разойтись им нечем. JSX и TSX сюда не входят: выход зависит от настройки
|
|
15
|
+
* `jsx` проекта (`React.createElement` против `react/jsx-runtime`), и мерить чужое
|
|
16
|
+
* решение о рантайме — не наше дело; такие файлы честно считаются упрощением. */
|
|
17
|
+
export const MINIFY_LOADERS = {
|
|
18
|
+
'.js': 'js', '.mjs': 'js', '.cjs': 'js',
|
|
19
|
+
'.ts': 'ts', '.mts': 'ts', '.cts': 'ts',
|
|
20
|
+
'.css': 'css'
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let probed = null;
|
|
24
|
+
|
|
25
|
+
/* Ответ разбора — один на процесс: пробовать загрузку на каждом файле значило бы
|
|
26
|
+
* платить за неё тысячи раз, а от файла решение не зависит. */
|
|
27
|
+
export function minifier() {
|
|
28
|
+
if (probed === null) probed = loadOptional('esbuild');
|
|
29
|
+
return probed;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Сжатие одного текста. Настройки выхода закреплены, а не взяты по умолчанию:
|
|
33
|
+
* `charset: utf8` — потому что измеряется файл проекта в UTF-8 (умолчание
|
|
34
|
+
* экранировало бы не-ASCII и число вышло бы больше настоящего), `legalComments:
|
|
35
|
+
* none` — потому что комментарии снимают и все прочие стратегии, и число должно
|
|
36
|
+
* означать одну вещь, а не две. `sourcefile` нужен ради причины в отказе. */
|
|
37
|
+
export function minifyWithEsbuild(text, file, rev) {
|
|
38
|
+
const { tool, why } = minifier();
|
|
39
|
+
if (tool === null) throw new Error('минификатор недоступен: ' + why);
|
|
40
|
+
const ext = path.extname(file).toLowerCase();
|
|
41
|
+
try {
|
|
42
|
+
return tool.transformSync(text, {
|
|
43
|
+
loader: MINIFY_LOADERS[ext],
|
|
44
|
+
minify: true,
|
|
45
|
+
charset: 'utf8',
|
|
46
|
+
legalComments: 'none',
|
|
47
|
+
sourcefile: file
|
|
48
|
+
}).code;
|
|
49
|
+
} catch (e) {
|
|
50
|
+
// Совет называет один выход — тот, который этой причине и отвечает: смена
|
|
51
|
+
// минификатора на `strip` уберёт причину, но передаст тот же файл гарду
|
|
52
|
+
// `minify.guard`, у которого разговор тот же («это не JavaScript»).
|
|
53
|
+
refuseCause('минификатор не разобрал', 'esbuild не разобрал ' + file + ' на '
|
|
54
|
+
+ rev.slice(0, 7) + ': ' + cause(e.message)
|
|
55
|
+
+ '\n починка: расширение соврало о содержимом или минификатор старше синтаксиса;'
|
|
56
|
+
+ ' задайте этому расширению упрощение в minify.ext (например {"' + ext + '": "strip-lines"})');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Причина у esbuild многострочная, и первая строка — «Transform failed with N
|
|
61
|
+
* errors:»; сама причина стоит там, где начинается ошибка. Без неё отказ говорил
|
|
62
|
+
* бы, что что-то не так, но не что именно. */
|
|
63
|
+
function cause(text) {
|
|
64
|
+
const lines = String(text).split('\n');
|
|
65
|
+
const at = lines.findIndex((line) => line.indexOf('ERROR:') >= 0);
|
|
66
|
+
return (at >= 0 ? lines[at] : lines[0]).trim();
|
|
67
|
+
}
|
package/src/optional.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
|
|
3
|
+
/* Необязательные зависимости: минификатор и токенизатор. Их отсутствие — не отказ,
|
|
4
|
+
* а другой счёт (упрощение вместо сжатия, оценка вместо точного счёта), поэтому
|
|
5
|
+
* загрузка у них общая и с одним устройством: ленивая, синхронная (`createRequire`
|
|
6
|
+
* — замер синхронный проход, и `import()` сделал бы асинхронной всю цепочку ради
|
|
7
|
+
* одного датчика) и без исключения наружу — недоступность возвращается ответом.
|
|
8
|
+
*
|
|
9
|
+
* Шов отсутствия — окружение `SIZE_REPORT_NO_OPTIONAL`: тем же путём идёт установка
|
|
10
|
+
* без необязательных зависимостей и платформа, для которой пакета нет. Им же
|
|
11
|
+
* проверяется, что инструмент работает и без них. */
|
|
12
|
+
|
|
13
|
+
export const NO_OPTIONAL = 'SIZE_REPORT_NO_OPTIONAL';
|
|
14
|
+
|
|
15
|
+
/* Версия берётся у самого пакета: число зависит от словаря и от алгоритма, поэтому
|
|
16
|
+
* она попадает в способ, которым получено значение, а не остаётся в `node_modules`. */
|
|
17
|
+
export function loadOptional(spec) {
|
|
18
|
+
if (process.env[NO_OPTIONAL]) {
|
|
19
|
+
return { tool: null, version: null, why: 'необязательные зависимости выключены (' + NO_OPTIONAL + ')' };
|
|
20
|
+
}
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
try {
|
|
23
|
+
const tool = require(spec);
|
|
24
|
+
const pkg = spec.split('/')[0];
|
|
25
|
+
let version = null;
|
|
26
|
+
try { version = require(pkg + '/package.json').version; } catch (_e) { version = null; }
|
|
27
|
+
return { tool: tool, version: version, why: null };
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return { tool: null, version: null, why: e.message };
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/page/app.css
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/* Оформление страницы отчёта — то, что стоит сверх общей таблицы (`src/table.css`):
|
|
2
|
+
* холст и типографика, панель выбора с легендой, состояния пустоты и адаптации под
|
|
3
|
+
* узкое окно. Чисел и цвета дельт здесь нет намеренно: их задаёт общая часть, и она
|
|
4
|
+
* же попадает в статический артефакт, поэтому двух наборов одной таблицы не
|
|
5
|
+
* бывает. Страница открывается с диска, без сервера и без сети, поэтому ни одной
|
|
6
|
+
* внешней ссылки в ней быть не может: только системные семейства шрифтов
|
|
7
|
+
* (`ui-sans-serif`) и системные цвета (`Canvas`, `CanvasText`, `AccentColor`),
|
|
8
|
+
* которые есть в любой теме.
|
|
9
|
+
*
|
|
10
|
+
* Общая часть заморожена байтами артефакта (`src/css.js`), поэтому всё, где
|
|
11
|
+
* страница расходится с её геометрией, собрано в разделе «адаптации» с причиной:
|
|
12
|
+
* растить общую часть нельзя, а на узком экране колонка коммита в 300px съедает
|
|
13
|
+
* весь экран. */
|
|
14
|
+
|
|
15
|
+
:root {
|
|
16
|
+
color-scheme: light dark;
|
|
17
|
+
--gap: 12px;
|
|
18
|
+
--pad: clamp(16px, 3vw, 34px);
|
|
19
|
+
--radius: 10px;
|
|
20
|
+
--muted: rgba(127, 127, 127, .85);
|
|
21
|
+
--line: rgba(127, 127, 127, .32);
|
|
22
|
+
--tint: rgba(127, 127, 127, .07);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* Типографика и ритм: один шаг между блоками (--gap), крупный заголовок,
|
|
26
|
+
* приглушённая подпись — чтобы взгляд доходил до чисел, а не до служебного текста. */
|
|
27
|
+
body {
|
|
28
|
+
margin: 0;
|
|
29
|
+
padding: var(--pad) var(--pad) 48px;
|
|
30
|
+
background: Canvas;
|
|
31
|
+
color: CanvasText;
|
|
32
|
+
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
33
|
+
-webkit-text-size-adjust: 100%;
|
|
34
|
+
}
|
|
35
|
+
h1 { margin: 0 0 3px; font-size: 21px; font-weight: 650; letter-spacing: -.012em; }
|
|
36
|
+
.sub { margin: 0 0 calc(var(--gap) + 4px); color: var(--muted); font-size: 12.5px; }
|
|
37
|
+
|
|
38
|
+
/* Панель выбора — карточка: она отделяет управление от данных и не сливается с
|
|
39
|
+
* таблицей, которая начинается ниже. */
|
|
40
|
+
.panel {
|
|
41
|
+
margin: 0 0 var(--gap);
|
|
42
|
+
padding: 12px 14px 13px;
|
|
43
|
+
border: 1px solid var(--line);
|
|
44
|
+
border-radius: var(--radius);
|
|
45
|
+
background: var(--tint);
|
|
46
|
+
}
|
|
47
|
+
.panel fieldset { margin: 0; padding: 0; border: 0; }
|
|
48
|
+
.panel fieldset + fieldset { margin-top: 10px; }
|
|
49
|
+
.panel legend, .panel .cap {
|
|
50
|
+
padding: 0;
|
|
51
|
+
color: var(--muted);
|
|
52
|
+
font-size: 11px;
|
|
53
|
+
font-weight: 600;
|
|
54
|
+
text-transform: uppercase;
|
|
55
|
+
letter-spacing: .07em;
|
|
56
|
+
}
|
|
57
|
+
.panel .cap { display: block; margin-bottom: 4px; }
|
|
58
|
+
.panel .row { display: flex; flex-wrap: wrap; gap: 3px 6px; align-items: center; }
|
|
59
|
+
/* Способ замера — видимым текстом под переключателями: словарь токенов и способ
|
|
60
|
+
* сжатия выбираются настройками запуска, а не галочкой, поэтому читателю мало
|
|
61
|
+
* навести мышь — он должен видеть, чем получено число. */
|
|
62
|
+
.panel .about { margin: 5px 0 0; color: var(--muted); font-size: 11.5px; }
|
|
63
|
+
|
|
64
|
+
/* Дерево файлов: вложенность показана отступом и линией уровня, папка — такой же
|
|
65
|
+
* переключатель, как файл, только его галочка отвечает за всё поддерево, а число
|
|
66
|
+
* рядом говорит, за сколько файлов. Список файлов длиннее окна, поэтому панель
|
|
67
|
+
* прокручивается сама: иначе управление вытолкнуло бы таблицу за экран. */
|
|
68
|
+
.panel .files { max-height: min(30vh, 320px); overflow: auto; }
|
|
69
|
+
.panel .tree { margin: 0; padding: 0; list-style: none; }
|
|
70
|
+
.panel .tree .tree { margin-left: 14px; padding-left: 9px; border-left: 1px solid var(--line); }
|
|
71
|
+
.panel .tree .box { padding: 1px 7px 1px 5px; }
|
|
72
|
+
.box.dir { font-weight: 600; }
|
|
73
|
+
.box .n { margin-left: 1px; color: var(--muted); font-size: 11px; }
|
|
74
|
+
|
|
75
|
+
/* Переключатель — метка вокруг поля ввода: и подпись, и цель нажатия одна, поэтому
|
|
76
|
+
* по нему попадает и мышь, и клавиатура (Space на поле ввода), и вспомогательные
|
|
77
|
+
* технологии. */
|
|
78
|
+
.box {
|
|
79
|
+
display: inline-flex;
|
|
80
|
+
gap: 6px;
|
|
81
|
+
align-items: center;
|
|
82
|
+
padding: 3px 9px 3px 7px;
|
|
83
|
+
border-radius: 999px;
|
|
84
|
+
cursor: pointer;
|
|
85
|
+
}
|
|
86
|
+
.box:hover { background: var(--tint); }
|
|
87
|
+
.box input { margin: 0; accent-color: AccentColor; }
|
|
88
|
+
.box.all { font-weight: 600; }
|
|
89
|
+
|
|
90
|
+
/* Легенда: те же классы цвета, что и в клетках (.up/.down из общей части), поэтому
|
|
91
|
+
* образец в легенде не может разойтись с цветом числа. */
|
|
92
|
+
.legend {
|
|
93
|
+
display: flex;
|
|
94
|
+
flex-wrap: wrap;
|
|
95
|
+
gap: 3px 18px;
|
|
96
|
+
margin: 10px 0 0;
|
|
97
|
+
padding: 0;
|
|
98
|
+
list-style: none;
|
|
99
|
+
color: var(--muted);
|
|
100
|
+
font-size: 12px;
|
|
101
|
+
}
|
|
102
|
+
.legend li { display: inline-flex; gap: 6px; align-items: center; }
|
|
103
|
+
.swatch { width: 10px; height: 10px; border-radius: 3px; background: currentColor; }
|
|
104
|
+
.swatch.same { background: none; border: 1px dashed currentColor; }
|
|
105
|
+
.swatch.miss { background: none; border: 1px solid currentColor; border-radius: 0; }
|
|
106
|
+
.swatch.approx { background: none; border-bottom: 2px dotted currentColor; }
|
|
107
|
+
|
|
108
|
+
/* Приближённое число помечено пунктиром, а не цветом: цвет в таблице занят дельтой
|
|
109
|
+
* (рост и спад), и второй смысл на том же признаке читался бы как первый. */
|
|
110
|
+
#grid td.approx { text-decoration: underline dotted; text-underline-offset: 2.5px; }
|
|
111
|
+
|
|
112
|
+
/* Таблица — в своей рамке и со своим скроллом: шапка и колонка коммита липнут к ней
|
|
113
|
+
* (правила липкости — в общей части), а не к странице, поэтому при прокрутке вбок
|
|
114
|
+
* видно, чей это ряд, а при прокрутке вниз — что за колонка. */
|
|
115
|
+
.shell {
|
|
116
|
+
overflow: auto;
|
|
117
|
+
max-height: calc(100vh - 300px);
|
|
118
|
+
border: 1px solid var(--line);
|
|
119
|
+
border-radius: var(--radius);
|
|
120
|
+
background: Canvas;
|
|
121
|
+
}
|
|
122
|
+
/* Числа плотнее текста страницы: их больше и они короче, а читаются по разрядам. */
|
|
123
|
+
#grid { font-size: 12.5px; }
|
|
124
|
+
|
|
125
|
+
/* Сообщение о присланной ссылке — над таблицей, чтобы пропустить его было нельзя,
|
|
126
|
+
* но таблицу оно не отодвигает: одна строка на месте страницы. Цвет берётся
|
|
127
|
+
* системный (акцент), своих цветов у страницы нет. */
|
|
128
|
+
.notice {
|
|
129
|
+
margin: 0 0 var(--gap);
|
|
130
|
+
padding: 9px 13px;
|
|
131
|
+
border-left: 3px solid AccentColor;
|
|
132
|
+
border-radius: 4px;
|
|
133
|
+
background: var(--tint);
|
|
134
|
+
font-size: 12.5px;
|
|
135
|
+
}
|
|
136
|
+
.notice[hidden] { display: none; }
|
|
137
|
+
|
|
138
|
+
/* Состояния пустоты: когда таблицу не из чего собрать, страница говорит об этом
|
|
139
|
+
* словами, а не пустой сеткой. */
|
|
140
|
+
.state {
|
|
141
|
+
margin: var(--gap) 0 0;
|
|
142
|
+
padding: 11px 13px;
|
|
143
|
+
border: 1px dashed var(--line);
|
|
144
|
+
border-radius: var(--radius);
|
|
145
|
+
color: var(--muted);
|
|
146
|
+
font-size: 12.5px;
|
|
147
|
+
}
|
|
148
|
+
.state[hidden], .shell[hidden] { display: none; }
|
|
149
|
+
|
|
150
|
+
.note { margin: 14px 0 0; max-width: 90em; color: var(--muted); font-size: 12px; }
|
|
151
|
+
.note code { background: var(--tint); padding: 0 3px; border-radius: 3px; }
|
|
152
|
+
|
|
153
|
+
/* Клавиатура: рамка фокуса видна на любом фоне (системный цвет акцента) и не
|
|
154
|
+
* сдвигает разметку. Ссылки журнала и все переключатели доступны с Tab. */
|
|
155
|
+
:focus-visible { outline: 2px solid AccentColor; outline-offset: 2px; border-radius: 3px; }
|
|
156
|
+
|
|
157
|
+
/* Адаптации: единственное место, где страница правит общую геометрию, — потому что
|
|
158
|
+
* общая часть заморожена байтами артефакта, а не потому что так удобнее. */
|
|
159
|
+
@media (max-width: 900px) {
|
|
160
|
+
body { padding: 14px 14px 32px; }
|
|
161
|
+
h1 { font-size: 18px; }
|
|
162
|
+
#grid { font-size: 12px; }
|
|
163
|
+
.shell { max-height: calc(100vh - 260px); }
|
|
164
|
+
.clip { width: 190px; }
|
|
165
|
+
}
|