@vernikr/size-report 1.2.0 → 1.3.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 +91 -0
- package/README.md +183 -71
- 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.css +30 -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/page/app.js
CHANGED
|
@@ -1,513 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* вклеивается в собранную страницу (`pageScript` снимает модульный синтаксис —
|
|
5
|
-
* импорт ниже и объявления с `export` из соседнего файла). Расчёт берётся из
|
|
6
|
-
* производных величин, поэтому включение метрики, категории или файла зовёт тот
|
|
7
|
-
* же расчёт, что считает статическую таблицу, и не может дать других чисел.
|
|
8
|
-
*
|
|
9
|
-
* Страница — один файл без внешних ссылок, поэтому здесь нет ни динамического
|
|
10
|
-
* импорта, ни загрузки чего-либо по сети: оформление приходит тем же файлом,
|
|
11
|
-
* а разметка клеток повторяет статическую таблицу (`clip` и подпись коммита —
|
|
12
|
-
* правила общей части оформления).
|
|
13
|
-
*
|
|
14
|
-
* Панель помнит выбор читателя между открытиями и умеет передать его ссылкой
|
|
15
|
-
* («Память выбора» ниже): запись привязана к паспорту отчёта и хранит только
|
|
16
|
-
* выключенное по именам, поэтому чужая запись не применяется, а исчезнувшее имя
|
|
17
|
-
* просто ничего не значит. Та же запись ложится в адрес — его и отправляют коллеге.
|
|
18
|
-
*
|
|
19
|
-
* Точность числа страница не выводит сама: пометки приближённых клеток приходят в
|
|
20
|
-
* данных, от того же правила, по которому названа точность метрики. Из путей и
|
|
21
|
-
* форматов страница такого вывода не делает — второго правила точности не будет. */
|
|
22
|
-
|
|
23
|
-
/* Импорт — одной строкой: модульный синтаксис снимается при вклейке построчно,
|
|
24
|
-
* и оставшаяся строка `import` попала бы в страницу (её ловит проверка). */
|
|
25
|
-
import { cellParts, commitParts, nowModel, rowModel, valueParts } from '../derived.js';
|
|
26
|
-
|
|
27
|
-
const appData = JSON.parse(document.getElementById('data').textContent);
|
|
28
|
-
const appUi = JSON.parse(document.getElementById('ui').textContent);
|
|
29
|
-
const appView = { metrics: {}, files: [] };
|
|
30
|
-
appData.metrics.forEach((m) => { appView.metrics[m.key] = true; });
|
|
31
|
-
appData.files.forEach(() => { appView.files.push(true); });
|
|
32
|
-
|
|
33
|
-
/* Описание метрики по ключу: подсказка приближённой клетки называет способ её
|
|
34
|
-
* числа — тот же, что стоит в подписи метрики, поэтому двух ответов про «чем
|
|
35
|
-
* посчитано» у страницы нет. */
|
|
36
|
-
const appMetric = {};
|
|
37
|
-
appData.metrics.forEach((m) => { appMetric[m.key] = m; });
|
|
38
|
-
|
|
39
|
-
/* Ссылка — это тот же выбор в адресе, под своим именем: чужой якорь страницы
|
|
40
|
-
* ссылкой не считается, и спорить с ним нечем. */
|
|
41
|
-
const APP_LINK = '#size-report=';
|
|
42
|
-
|
|
43
|
-
/* Три обстоятельства первой отрисовки, которые действуют только на ней:
|
|
44
|
-
* адрес в ней не переписывается (его прислали читателю, а не наоборот), память
|
|
45
|
-
* не трогается (присланная ссылка — не выбор читателя), а сообщение о ссылке
|
|
46
|
-
* ещё не гаснет. */
|
|
47
|
-
let appStartup = true;
|
|
48
|
-
let appForeign = false;
|
|
49
|
-
let appTransient = false;
|
|
50
|
-
|
|
51
|
-
/* -------- память выбора читателя -------- */
|
|
52
|
-
|
|
53
|
-
/* Имя файла для записи — путь на HEAD, а если файла там уже нет, последний из
|
|
54
|
-
* настроек: по нему файл и опознаётся в отчёте. */
|
|
55
|
-
function appFileAt(i) {
|
|
56
|
-
const f = appData.files[i];
|
|
57
|
-
return f.path === null ? f.paths[0] : f.path;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/* Отпечаток паспорта: опознавательный знак записи, а не защита от подделки,
|
|
61
|
-
* поэтому 32 бит достаточно (FNV-1a). */
|
|
62
|
-
function appHash(text) {
|
|
63
|
-
let h = 2166136261;
|
|
64
|
-
for (let i = 0; i < text.length; i++) {
|
|
65
|
-
h ^= text.charCodeAt(i);
|
|
66
|
-
h = Math.imul(h, 16777619);
|
|
67
|
-
}
|
|
68
|
-
return (h >>> 0).toString(16);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/* Паспорт отчёта: имя инструмента, схема данных, путь артефакта, заголовок и метки
|
|
72
|
-
* колонок в порядке отчёта. Он и отделяет один отчёт от другого — по нему выбирается
|
|
73
|
-
* ключ записи, поэтому выбор с чужого отчёта не подхватывается. Версии пакета и
|
|
74
|
-
* верхушки истории в паспорте нет намеренно: это тот же отчёт — обновление
|
|
75
|
-
* инструмента не меняет того, что значит колонка, а подросшая история это та же
|
|
76
|
-
* история, к которой читатель и возвращается. */
|
|
77
|
-
function appPassport() {
|
|
78
|
-
return appHash([appData.tool.name, appData.schema, appData.report.artifact,
|
|
79
|
-
appData.report.title, appData.files.map((f) => f.label).join('|')].join('\n'));
|
|
80
|
-
}
|
|
81
|
-
const appKey = 'size-report:' + appPassport();
|
|
82
|
-
|
|
83
|
-
/* Запись выбора — одна на всё: её кладут и в память, и в адрес, поэтому двух
|
|
84
|
-
* форматов одного состояния не бывает. Хранится только выключенное, по именам:
|
|
85
|
-
* «включено» и «записи нет» — одно и то же состояние, поэтому возврат всех галочек
|
|
86
|
-
* убирает запись, а не оставляет след, неотличимый от выбора. */
|
|
87
|
-
function appRecord() {
|
|
88
|
-
const metrics = {};
|
|
89
|
-
const files = {};
|
|
90
|
-
appData.metrics.forEach((m) => { if (!appView.metrics[m.key]) metrics[m.key] = false; });
|
|
91
|
-
appData.files.forEach((_f, i) => { if (!appView.files[i]) files[appFileAt(i)] = false; });
|
|
92
|
-
return { v: 1, passport: appPassport(), metrics: metrics, files: files };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Своя ли запись и того ли формата — одно правило и для памяти, и для адреса.
|
|
96
|
-
function appRecordOk(rec) {
|
|
97
|
-
return rec !== null && typeof rec === 'object' && rec.v === 1 && rec.passport === appPassport();
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function appWrite() {
|
|
101
|
-
const rec = appRecord();
|
|
102
|
-
const empty = Object.keys(rec.metrics).length === 0 && Object.keys(rec.files).length === 0;
|
|
103
|
-
if (!appTransient) {
|
|
104
|
-
try {
|
|
105
|
-
if (empty) window.localStorage.removeItem(appKey);
|
|
106
|
-
else window.localStorage.setItem(appKey, JSON.stringify(rec));
|
|
107
|
-
} catch (_e) {
|
|
108
|
-
/* Памяти нет (браузер её не даёт этой странице): выбор не переживёт закрытия,
|
|
109
|
-
* а числа и разметка от этого не зависят. */
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
/* Адрес и есть ссылка для коллеги, поэтому он повторяет выбор. Но не на первой
|
|
113
|
-
* отрисовке и не тогда, когда ссылка оказалась чужой: присланный адрес — не наш,
|
|
114
|
-
* его читателю ещё читать. */
|
|
115
|
-
if (appStartup || appForeign) return;
|
|
116
|
-
try {
|
|
117
|
-
window.history.replaceState(null, '', APP_LINK + encodeURIComponent(JSON.stringify(rec)));
|
|
118
|
-
} catch (_e) {
|
|
119
|
-
/* Браузер не даёт менять адрес: ссылку тогда берут из памяти браузера. */
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/* Сброс к «включено всё»: граница между «в отчёте этого больше нет» и
|
|
124
|
-
* «выключено» — это запись, а не отсутствие значения. Ссылка несёт весь выбор
|
|
125
|
-
* отправителя, поэтому применяется на чистом виде, а не поверх чужого. */
|
|
126
|
-
function appAll() {
|
|
127
|
-
appData.metrics.forEach((m) => { appView.metrics[m.key] = true; });
|
|
128
|
-
appData.files.forEach((_f, i) => { appView.files[i] = true; });
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/* Что говорит адрес. Отвечает либо своей записью, либо отказом (`linkForeign` —
|
|
132
|
-
* ссылка другого отчёта, `linkBroken` — прочитать нечего): чужой или испорченный
|
|
133
|
-
* выбор не применяется, но и не молчит — иначе читатель не поймёт, почему он видит
|
|
134
|
-
* не то, что ему прислали. Адрес без имени ссылки не ссылка вовсе: молчание, чтобы
|
|
135
|
-
* не спорить с обычными якорями страницы. */
|
|
136
|
-
function appLinkRead() {
|
|
137
|
-
const hash = window.location.hash || '';
|
|
138
|
-
if (hash.indexOf(APP_LINK) !== 0) return { rec: null, refused: null };
|
|
139
|
-
const body = hash.slice(APP_LINK.length);
|
|
140
|
-
let rec = null;
|
|
141
|
-
try {
|
|
142
|
-
rec = JSON.parse(decodeURIComponent(body));
|
|
143
|
-
} catch (_e) {
|
|
144
|
-
try {
|
|
145
|
-
rec = JSON.parse(body);
|
|
146
|
-
} catch (_e2) {
|
|
147
|
-
return { rec: null, refused: 'linkBroken' };
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
if (!appRecordOk(rec)) {
|
|
151
|
-
return { rec: null, refused: rec === null || typeof rec !== 'object' || rec.v !== 1
|
|
152
|
-
? 'linkBroken' : 'linkForeign' };
|
|
153
|
-
}
|
|
154
|
-
return { rec: rec, refused: null, extra: appUnknown(rec) };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/* Сколько имён в ссылке этому отчёту неизвестны: о них читателю надо сказать —
|
|
158
|
-
* иначе он будет искать в таблице то, чего в ней и не было. */
|
|
159
|
-
function appUnknown(rec) {
|
|
160
|
-
const known = {};
|
|
161
|
-
const metricKeys = {};
|
|
162
|
-
appData.files.forEach((_f, i) => { known[appFileAt(i)] = true; });
|
|
163
|
-
appData.metrics.forEach((m) => { metricKeys[m.key] = true; });
|
|
164
|
-
let n = 0;
|
|
165
|
-
Object.keys(rec.metrics || {}).forEach((k) => { if (!metricKeys[k]) n++; });
|
|
166
|
-
Object.keys(rec.files || {}).forEach((k) => { if (!known[k]) n++; });
|
|
167
|
-
return n;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/* Сообщение о ссылке гаснет после первого же действия читателя: он его прочитал, а
|
|
171
|
-
* постоянное предупреждение — это шум поверх чисел. */
|
|
172
|
-
function appNotice(text) {
|
|
173
|
-
const el = document.getElementById('notice');
|
|
174
|
-
el.textContent = text;
|
|
175
|
-
el.hidden = text === '';
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/* Что делает с адресом его событие — открытие страницы и перемена якоря на уже
|
|
179
|
-
* открытой (браузер в этом случае документ не перезагружает, а лишь переставляет
|
|
180
|
-
* якорь, поэтому без этого разбора ссылка работала бы только в новой вкладке).
|
|
181
|
-
* Своя ссылка заменяет вид целиком: в ней весь выбор отправителя, а не разница с
|
|
182
|
-
* чужим. Отказ объясняется словами — и не трогает ни вид, ни адрес. */
|
|
183
|
-
function appLinkUse() {
|
|
184
|
-
const link = appLinkRead();
|
|
185
|
-
if (link.rec !== null) {
|
|
186
|
-
appAll();
|
|
187
|
-
appApply(link.rec);
|
|
188
|
-
if (link.extra > 0) appNotice(appUi.linkExtra.replace('{n}', link.extra));
|
|
189
|
-
return 'ours';
|
|
190
|
-
}
|
|
191
|
-
if (link.refused !== null) {
|
|
192
|
-
appNotice(appUi[link.refused]);
|
|
193
|
-
return 'refused';
|
|
194
|
-
}
|
|
195
|
-
return 'none';
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/* Чтение: только своя запись — своей версии формата и своего паспорта. Запись
|
|
199
|
-
* чужого отчёта лежит под другим ключом, а чужая, устаревшая или испорченная
|
|
200
|
-
* равносильна её отсутствию. */
|
|
201
|
-
function appRead() {
|
|
202
|
-
let text = null;
|
|
203
|
-
try {
|
|
204
|
-
text = window.localStorage.getItem(appKey);
|
|
205
|
-
} catch (_e) {
|
|
206
|
-
return null;
|
|
207
|
-
}
|
|
208
|
-
if (text === null) return null;
|
|
209
|
-
let rec = null;
|
|
210
|
-
try {
|
|
211
|
-
rec = JSON.parse(text);
|
|
212
|
-
} catch (_e) {
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
return appRecordOk(rec) ? rec : null;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/* Применение — по именам: файл опознаётся путём, метрика ключом. Имени, которого в
|
|
219
|
-
* отчёте нет, ничего не соответствует (колонку перенаправили на другой путь,
|
|
220
|
-
* метрику убрали из настроек), а появившиеся файлы и метрики остаются включёнными —
|
|
221
|
-
* как их видит тот, кто открыл страницу впервые. */
|
|
222
|
-
function appApply(rec) {
|
|
223
|
-
const metrics = rec.metrics || {};
|
|
224
|
-
const files = rec.files || {};
|
|
225
|
-
appData.metrics.forEach((m) => { if (metrics[m.key] === false) appView.metrics[m.key] = false; });
|
|
226
|
-
appData.files.forEach((_f, i) => { if (files[appFileAt(i)] === false) appView.files[i] = false; });
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function appEl(tag, cls, text) {
|
|
230
|
-
const el = document.createElement(tag);
|
|
231
|
-
if (cls) el.className = cls;
|
|
232
|
-
if (text !== undefined) el.textContent = text;
|
|
233
|
-
return el;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/* Переключатель — метка вокруг поля ввода: цель нажатия одна, поэтому по нему
|
|
237
|
-
* попадают и мышь, и клавиатура (`Space` на поле ввода), и вспомогательные
|
|
238
|
-
* технологии. Подпись видимая, подробности — во всплывающей строке. */
|
|
239
|
-
function appBox(label, title, checked, onChange, cls) {
|
|
240
|
-
const box = appEl('label', 'box' + (cls ? ' ' + cls : ''));
|
|
241
|
-
const input = document.createElement('input');
|
|
242
|
-
input.type = 'checkbox';
|
|
243
|
-
input.checked = checked;
|
|
244
|
-
if (title) input.title = title;
|
|
245
|
-
input.addEventListener('change', onChange);
|
|
246
|
-
box.appendChild(input);
|
|
247
|
-
box.appendChild(appEl('span', null, label));
|
|
248
|
-
return box;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/* Легенда: образцы — теми же классами, что и числа в клетках (`up`/`down` из
|
|
252
|
-
* общей части оформления), поэтому образец не может разойтись с цветом числа. */
|
|
253
|
-
function appLegend() {
|
|
254
|
-
const list = appEl('ul', 'legend');
|
|
255
|
-
appUi.legend.forEach((item) => {
|
|
256
|
-
const li = appEl('li');
|
|
257
|
-
li.appendChild(appEl('span', 'swatch ' + item.cls));
|
|
258
|
-
li.appendChild(appEl('span', null, item.text));
|
|
259
|
-
list.appendChild(li);
|
|
260
|
-
});
|
|
261
|
-
return list;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/* Галочку файла ставит только файл: и категория, и папка в дереве — способы
|
|
265
|
-
* переставить те же галочки сразу группой, а своего состояния у них нет. Иначе
|
|
266
|
-
* одно и то же решение жило бы в двух местах и расходилось. */
|
|
267
|
-
function appFileBox(i) {
|
|
268
|
-
const f = appData.files[i];
|
|
269
|
-
const where = appFileAt(i) + (f.path === null ? ' (нет на HEAD)' : '');
|
|
270
|
-
return appBox(f.label, where + ' · категория: '
|
|
271
|
-
+ (f.categoryBy === 'config' ? 'из настроек' : 'по расширению'), appView.files[i], (e) => {
|
|
272
|
-
appView.files[i] = e.target.checked;
|
|
273
|
-
appRender();
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/* Все файлы поддерева — то, чем управляет переключатель папки. */
|
|
278
|
-
function appIndexes(node) {
|
|
279
|
-
const out = node.files.slice();
|
|
280
|
-
node.dirs.forEach((sub) => { out.push(...appIndexes(sub)); });
|
|
281
|
-
return out;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/* Узлы одного уровня: сперва папки по алфавиту, затем файлы в порядке данных.
|
|
285
|
-
* Переключатель папки стоит над своим поддеревом и показывает три состояния: все
|
|
286
|
-
* файлы включены, часть, ни одного. */
|
|
287
|
-
function appTreeList(node) {
|
|
288
|
-
const list = appEl('ul', 'tree');
|
|
289
|
-
[...node.dirs.keys()].sort().forEach((name) => {
|
|
290
|
-
const sub = node.dirs.get(name);
|
|
291
|
-
const idx = appIndexes(sub);
|
|
292
|
-
const on = idx.map((i) => appView.files[i]);
|
|
293
|
-
const some = on.some((v) => v);
|
|
294
|
-
const head = appBox(name + '/', appUi.dir.replace('{name}', name).replace('{n}', idx.length),
|
|
295
|
-
some && on.every((v) => v), (e) => {
|
|
296
|
-
idx.forEach((i) => { appView.files[i] = e.target.checked; });
|
|
297
|
-
appRender();
|
|
298
|
-
}, 'dir');
|
|
299
|
-
head.querySelector('input').indeterminate = some && !on.every((v) => v);
|
|
300
|
-
head.appendChild(appEl('span', 'n', idx.length));
|
|
301
|
-
const li = appEl('li');
|
|
302
|
-
li.appendChild(head);
|
|
303
|
-
li.appendChild(appTreeList(sub));
|
|
304
|
-
list.appendChild(li);
|
|
305
|
-
});
|
|
306
|
-
node.files.forEach((i) => {
|
|
307
|
-
const li = appEl('li');
|
|
308
|
-
li.appendChild(appFileBox(i));
|
|
309
|
-
list.appendChild(li);
|
|
310
|
-
});
|
|
311
|
-
return list;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/* Дерево файлов: путь делится по «/», папки становятся узлами, файлы — листьями.
|
|
315
|
-
* Строится из тех же путей, что показаны в подписи файла, поэтому дерево и список
|
|
316
|
-
* файлов не могут разойтись. */
|
|
317
|
-
function appTree() {
|
|
318
|
-
const root = { files: [], dirs: new Map() };
|
|
319
|
-
appData.files.forEach((f, i) => {
|
|
320
|
-
const parts = (f.path === null ? f.paths[0] : f.path).split('/');
|
|
321
|
-
let node = root;
|
|
322
|
-
for (let d = 0; d < parts.length - 1; d++) {
|
|
323
|
-
if (!node.dirs.has(parts[d])) node.dirs.set(parts[d], { files: [], dirs: new Map() });
|
|
324
|
-
node = node.dirs.get(parts[d]);
|
|
325
|
-
}
|
|
326
|
-
node.files.push(i);
|
|
327
|
-
});
|
|
328
|
-
return appTreeList(root);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function appPanel() {
|
|
332
|
-
const panel = document.getElementById('panel');
|
|
333
|
-
panel.textContent = '';
|
|
334
|
-
|
|
335
|
-
const metrics = appEl('fieldset');
|
|
336
|
-
metrics.appendChild(appEl('legend', null, appUi.metrics));
|
|
337
|
-
const mrow = appEl('div', 'row');
|
|
338
|
-
appData.metrics.forEach((m) => {
|
|
339
|
-
const word = m.accuracy === 'exact' ? appUi.exact : appUi.approximate;
|
|
340
|
-
mrow.appendChild(appBox(m.label, m.note + ' · ' + word, appView.metrics[m.key], (e) => {
|
|
341
|
-
appView.metrics[m.key] = e.target.checked;
|
|
342
|
-
appRender();
|
|
343
|
-
}, 'metric'));
|
|
344
|
-
});
|
|
345
|
-
metrics.appendChild(mrow);
|
|
346
|
-
/* Чем получено каждое число — видно, а не только во всплывающей строке: словарь
|
|
347
|
-
* токенов и способ сжатия выбираются настройками запуска, переключить их
|
|
348
|
-
* странице нечем, и читателю важно знать это, не наводя мышь. */
|
|
349
|
-
appData.metrics.forEach((m) => {
|
|
350
|
-
metrics.appendChild(appEl('p', 'about', m.label + ' — ' + appUi.methodLabel + ' ' + m.method));
|
|
351
|
-
});
|
|
352
|
-
panel.appendChild(metrics);
|
|
353
|
-
|
|
354
|
-
const files = appEl('fieldset', 'files');
|
|
355
|
-
files.appendChild(appEl('legend', null, appUi.files));
|
|
356
|
-
const cats = appEl('div', 'row');
|
|
357
|
-
appData.categories.forEach((cat) => {
|
|
358
|
-
const idx = [];
|
|
359
|
-
appData.files.forEach((f, i) => { if (f.category === cat.key) idx.push(i); });
|
|
360
|
-
cats.appendChild(appBox(cat.label, appUi.all + ' · ' + cat.label, idx.every((i) => appView.files[i]),
|
|
361
|
-
(e) => {
|
|
362
|
-
idx.forEach((i) => { appView.files[i] = e.target.checked; });
|
|
363
|
-
appRender();
|
|
364
|
-
}, 'all'));
|
|
365
|
-
});
|
|
366
|
-
files.appendChild(cats);
|
|
367
|
-
files.appendChild(appTree());
|
|
368
|
-
panel.appendChild(files);
|
|
369
|
-
|
|
370
|
-
panel.appendChild(appLegend());
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/* Приближённая клетка: пометка берётся из данных движка, а не выводится здесь из
|
|
374
|
-
* пути файла, — правило точности живёт там же, где считаются числа. У клетки
|
|
375
|
-
* файла это её собственное число, у итога — худшее из вошедших в него, иначе
|
|
376
|
-
* сумма обещала бы точность, которой нет у слагаемых. Возвращается описание
|
|
377
|
-
* метрики (её способ и идёт в подсказку клетки) либо ничего. */
|
|
378
|
-
function appApprox(where, files, key) {
|
|
379
|
-
const marks = appData.approx[key];
|
|
380
|
-
if (marks === undefined) return null;
|
|
381
|
-
const row = where === 'now' ? marks.now : marks.rows;
|
|
382
|
-
const base = where === 'now' ? 0 : where * appData.files.length;
|
|
383
|
-
for (let i = 0; i < files.length; i++) {
|
|
384
|
-
if (row.charAt(base + files[i]) === '1') return appMetric[key];
|
|
385
|
-
}
|
|
386
|
-
return null;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/* Класс клетки собирается в одном месте: и пометка приближения, и пропуск
|
|
390
|
-
* («файла нет») — свойства самой клетки, а не её содержимого. */
|
|
391
|
-
function appCellClass(first, miss, approx) {
|
|
392
|
-
return 'num' + (first ? ' g' : '') + (miss ? ' miss' : '') + (approx === null ? '' : ' approx');
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Разметка клетки строки-коммита: правила — в cellParts, здесь только узел.
|
|
396
|
-
function appCell(cell, first, approx) {
|
|
397
|
-
const parts = cellParts(cell.value, cell.delta, '−');
|
|
398
|
-
const td = appEl('td', appCellClass(first, parts.miss, approx));
|
|
399
|
-
if (approx !== null) td.title = appUi.approxCell + approx.method;
|
|
400
|
-
if (parts.dir === null) td.textContent = parts.text;
|
|
401
|
-
else td.appendChild(appEl('span', 'delta ' + parts.dir, parts.text));
|
|
402
|
-
return td;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// Разметка клетки верхней строки: правила — в valueParts.
|
|
406
|
-
function appValueCell(value, first, approx) {
|
|
407
|
-
const parts = valueParts(value);
|
|
408
|
-
const td = appEl('td', appCellClass(first, parts.miss, approx));
|
|
409
|
-
if (approx !== null) td.title = appUi.approxCell + approx.method;
|
|
410
|
-
td.textContent = parts.text;
|
|
411
|
-
return td;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/* Подпись коммита — той же разметкой, что в статической таблице: дата, тема,
|
|
415
|
-
* метка раздела журнала. Ширину колонки и обрезку длинной темы задаёт общая часть
|
|
416
|
-
* оформления, поэтому колонка не прыгает при переключении файлов. */
|
|
417
|
-
function appCommit(row) {
|
|
418
|
-
const parts = commitParts(row, appData.report.showSha, row.href);
|
|
419
|
-
const name = parts.href ? appEl('a', 'subj', parts.subject) : appEl('span', 'subj', parts.subject);
|
|
420
|
-
if (parts.href) name.href = parts.href;
|
|
421
|
-
name.title = parts.title;
|
|
422
|
-
const mark = appEl('span', 'sect', parts.mark.text);
|
|
423
|
-
if (parts.mark.title) mark.title = parts.mark.title;
|
|
424
|
-
|
|
425
|
-
const clip = appEl('div', 'clip');
|
|
426
|
-
clip.appendChild(appEl('span', 'when', parts.when));
|
|
427
|
-
clip.appendChild(name);
|
|
428
|
-
clip.appendChild(mark);
|
|
429
|
-
const th = appEl('th', 'c-commit');
|
|
430
|
-
th.appendChild(clip);
|
|
431
|
-
return th;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function appSubHead(metrics) {
|
|
435
|
-
const tr = appEl('tr');
|
|
436
|
-
metrics.forEach((m, mi) => tr.appendChild(appEl('th', mi === 0 ? 'g' : '', m.label)));
|
|
437
|
-
return tr;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/* Состояния пустоты: когда чисел не будет вовсе, страница говорит об этом словами,
|
|
441
|
-
* а не сеткой без колонок. Файлы можно выключить все — тогда остаётся общий объём,
|
|
442
|
-
* и подсказка объясняет, почему колонок нет. */
|
|
443
|
-
function appState(metricsCount, filesCount) {
|
|
444
|
-
const state = document.getElementById('state');
|
|
445
|
-
const text = metricsCount === 0 ? appUi.empty : (filesCount === 0 ? appUi.noFiles : '');
|
|
446
|
-
state.textContent = text;
|
|
447
|
-
state.hidden = text === '';
|
|
448
|
-
document.getElementById('shell').hidden = metricsCount === 0;
|
|
449
|
-
}
|
|
1
|
+
import { appData, appUi, appView, appWrite, appNotice, appLinkUse, appRead, appApply } from './state.js';
|
|
2
|
+
import { appBody, appHead, appState } from './table.js';
|
|
3
|
+
import { appPanel } from './panel.js';
|
|
450
4
|
|
|
5
|
+
/* Сборка таблицы: что показывать (метрики и файлы, оставленные читателем) и куда
|
|
6
|
+
* это положить. Разметку шапки и строк строит глава таблицы, числа — общий расчёт:
|
|
7
|
+
* здесь остаётся только решение и вставка, своих чисел у сборки нет. */
|
|
451
8
|
function appTable() {
|
|
452
9
|
const shown = appData.metrics.filter((m) => appView.metrics[m.key]);
|
|
453
10
|
const metrics = shown.map((m) => m.key);
|
|
454
11
|
const on = appView.files;
|
|
455
12
|
const files = [];
|
|
456
13
|
appData.files.forEach((f, i) => { if (on[i]) files.push(i); });
|
|
14
|
+
|
|
457
15
|
const table = document.getElementById('grid');
|
|
458
16
|
table.textContent = '';
|
|
459
17
|
appState(metrics.length, files.length);
|
|
460
18
|
if (metrics.length === 0) return;
|
|
461
19
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
commit.rowSpan = 2;
|
|
465
|
-
head.appendChild(commit);
|
|
466
|
-
const total = appEl('th', 'g', appUi.total);
|
|
467
|
-
total.colSpan = metrics.length;
|
|
468
|
-
head.appendChild(total);
|
|
469
|
-
files.forEach((i) => {
|
|
470
|
-
const th = appEl('th', 'g', appData.files[i].label);
|
|
471
|
-
th.colSpan = metrics.length;
|
|
472
|
-
head.appendChild(th);
|
|
473
|
-
});
|
|
474
|
-
const subs = appSubHead(shown);
|
|
475
|
-
files.forEach(() => {
|
|
476
|
-
const more = appSubHead(shown);
|
|
477
|
-
while (more.firstChild) subs.appendChild(more.firstChild);
|
|
478
|
-
});
|
|
479
|
-
const thead = appEl('thead');
|
|
480
|
-
thead.appendChild(head);
|
|
481
|
-
thead.appendChild(subs);
|
|
482
|
-
|
|
483
|
-
const body = appEl('tbody');
|
|
484
|
-
for (let r = appData.rows.length - 1; r >= 0; r--) {
|
|
485
|
-
const row = appData.rows[r];
|
|
486
|
-
const prev = r === 0 ? null : appData.rows[r - 1];
|
|
487
|
-
const model = rowModel(row.values, prev === null ? null : prev.values, metrics, on);
|
|
488
|
-
const tr = appEl('tr');
|
|
489
|
-
tr.appendChild(appCommit(row));
|
|
490
|
-
model.total.forEach((cell, mi) => tr.appendChild(appCell(cell, mi === 0, appApprox(r, files, metrics[mi]))));
|
|
491
|
-
model.files.forEach((cells, fi) => cells.forEach((cell, mi) => {
|
|
492
|
-
tr.appendChild(appCell(cell, mi === 0, appApprox(r, [files[fi]], metrics[mi])));
|
|
493
|
-
}));
|
|
494
|
-
body.appendChild(tr);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
/* Верхняя строка — «сейчас»: абсолютные размеры на HEAD. Дельты под ней сходятся
|
|
498
|
-
* с ней, поэтому она и стоит первой. */
|
|
499
|
-
const now = appEl('tr', 'now');
|
|
500
|
-
now.appendChild(appEl('th', 'c-commit', appUi.now));
|
|
501
|
-
const nowCells = nowModel(appData.now, metrics, on);
|
|
502
|
-
nowCells.total.forEach((v, mi) => now.appendChild(appValueCell(v, mi === 0,
|
|
503
|
-
appApprox('now', files, metrics[mi]))));
|
|
504
|
-
nowCells.files.forEach((cells, fi) => cells.forEach((v, mi) => {
|
|
505
|
-
now.appendChild(appValueCell(v, mi === 0, appApprox('now', [files[fi]], metrics[mi])));
|
|
506
|
-
}));
|
|
507
|
-
body.insertBefore(now, body.firstChild);
|
|
508
|
-
|
|
509
|
-
table.appendChild(thead);
|
|
510
|
-
table.appendChild(body);
|
|
20
|
+
table.appendChild(appHead(shown, files, metrics));
|
|
21
|
+
table.appendChild(appBody(metrics, files));
|
|
511
22
|
document.getElementById('note').textContent = appUi.note
|
|
512
23
|
.replace('{rows}', appData.rows.length)
|
|
513
24
|
.replace('{command}', appData.report.fixCommand);
|
package/src/page/build.js
CHANGED
|
@@ -4,15 +4,20 @@ import { esc } from '../render.js';
|
|
|
4
4
|
import { PAGE_CSS, TABLE_CSS } from '../css.js';
|
|
5
5
|
|
|
6
6
|
/* Сборка страницы отчёта: данные и программа в одном файле, внешних ссылок нет.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
|
|
11
|
-
/* Программа страницы — обычные исходники (`src/page/app.js` и общий расчёт
|
|
7
|
+
* Оформление — тоже обычные файлы: общая часть таблицы (`table.css`) и своё
|
|
8
|
+
* оформление страницы (`app.css`).
|
|
9
|
+
*
|
|
10
|
+
* Программа страницы — обычные исходники (главы `src/page/*.js` и общий расчёт
|
|
12
11
|
* `src/derived.js`), а не строки в движке: их видит линтер, их же движок
|
|
13
12
|
* вклеивает в страницу. Модульный синтаксис снимается при вклейке: в браузере,
|
|
14
13
|
* открывшем файл с диска, разрешать `import` нечем, а объявления обязаны попасть
|
|
15
|
-
* в общую область видимости в порядке вклейки — сперва расчёт, затем
|
|
14
|
+
* в общую область видимости в порядке вклейки — сперва расчёт, затем главы.
|
|
15
|
+
*
|
|
16
|
+
* Главы — по предметам страницы, и порядок вклейки (список ниже) — это порядок
|
|
17
|
+
* объявлений в собранной программе: первым идёт расчёт, за ним состояние
|
|
18
|
+
* выбора, узлы, панель, таблица и сборка. Главы — **срезы одного текста**: вклейка
|
|
19
|
+
* склеивает их подряд, поэтому собранная страница осталась бы той же, если бы
|
|
20
|
+
* главы снова стали одним файлом. */
|
|
16
21
|
export function stripModules(src) {
|
|
17
22
|
return src.split('\n')
|
|
18
23
|
.filter((line) => !/^import\s.*;\s*$/.test(line))
|
|
@@ -24,8 +29,12 @@ export function pageSource(file) {
|
|
|
24
29
|
return stripModules(fs.readFileSync(new URL(file, import.meta.url), 'utf8'));
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
/* Список глав — здесь же, а не в проверках: он один на сборку и на сторожа
|
|
33
|
+
* (`test/page-view.test.js` читает ту же программу и сверяет её с исходниками). */
|
|
34
|
+
export const PAGE_PARTS = ['./state.js', './dom.js', './panel.js', './table.js', './app.js'];
|
|
35
|
+
|
|
27
36
|
export function pageScript() {
|
|
28
|
-
return pageSource('../derived.js') + '\n' + pageSource(
|
|
37
|
+
return pageSource('../derived.js') + '\n' + PAGE_PARTS.map((part) => pageSource(part)).join('');
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
/* Подпись под заголовком: чем собрана страница и что у неё рядом. Путь к таблице
|
package/src/page/dom.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
export function appEl(tag, cls, text) {
|
|
3
|
+
const el = document.createElement(tag);
|
|
4
|
+
if (cls) el.className = cls;
|
|
5
|
+
if (text !== undefined) el.textContent = text;
|
|
6
|
+
return el;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* Переключатель — метка вокруг поля ввода: цель нажатия одна, поэтому по нему
|
|
10
|
+
* попадают и мышь, и клавиатура (`Space` на поле ввода), и вспомогательные
|
|
11
|
+
* технологии. Подпись видимая, подробности — во всплывающей строке. */
|
|
12
|
+
export function appBox(label, title, checked, onChange, cls) {
|
|
13
|
+
const box = appEl('label', 'box' + (cls ? ' ' + cls : ''));
|
|
14
|
+
const input = document.createElement('input');
|
|
15
|
+
input.type = 'checkbox';
|
|
16
|
+
input.checked = checked;
|
|
17
|
+
if (title) input.title = title;
|
|
18
|
+
input.addEventListener('change', onChange);
|
|
19
|
+
box.appendChild(input);
|
|
20
|
+
box.appendChild(appEl('span', null, label));
|
|
21
|
+
return box;
|
|
22
|
+
}
|