@vernikr/size-report 2.5.0 → 2.6.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/README.md +124 -64
- package/package.json +2 -2
- package/src/check.js +1 -1
- package/src/data.js +0 -18
- package/src/doctor.js +4 -4
- package/src/history.js +3 -9
- package/src/init.js +2 -1
- package/src/locales.js +9 -9
- package/src/metrics.js +45 -63
- package/src/minify.js +2 -2
- package/src/modes.js +8 -8
- package/src/optional.js +2 -2
- package/src/page/app.css +6 -8
- package/src/page/app.js +116 -67
- package/src/page/build.js +161 -19
- package/src/page/panel.js +111 -20
- package/src/page/payload.js +168 -0
- package/src/page/state.js +87 -32
- package/src/page/table.js +260 -73
- package/src/parse.js +1 -1
- package/src/project.js +2 -2
- package/src/strip/guard.js +4 -3
- package/src/strip.js +4 -5
- package/src/table.css +44 -8
- package/src/tokens.js +5 -6
- package/templates/README.md +3 -3
package/src/page/build.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import zlib from 'zlib';
|
|
2
3
|
import { fill, LOCALES } from '../locales.js';
|
|
3
4
|
import { PAGE_CSS, TABLE_CSS } from '../css.js';
|
|
5
|
+
import { assertCompilable, stripCss, stripJs, stripLines } from '../strip.js';
|
|
4
6
|
|
|
5
7
|
/* Escaping text for markup lives here, because this builder is the only place that turns data into markup: the rest
|
|
6
8
|
* is drawn as nodes by the page. */
|
|
@@ -17,6 +19,9 @@ export function esc(s) {
|
|
|
17
19
|
* `import` with, and the declarations have to reach the shared scope in the order of pasting — the calculation first,
|
|
18
20
|
* then the chapters.
|
|
19
21
|
*
|
|
22
|
+
* What is pasted is **squeezed** on the way in (`squeezedJs`, `squeezedCss`): comments and indentation leave only the
|
|
23
|
+
* copy inside the report, which is read in a browser, while the sources stay the ordinary files a person edits.
|
|
24
|
+
*
|
|
20
25
|
* The chapters follow the page's subjects, and the order of pasting (the list below) is the order of declarations in
|
|
21
26
|
* the assembled program: the calculation first, then the choice's state, the nodes, the panel, the table and the
|
|
22
27
|
* assembling. The chapters are **slices of one text**: pasting glues them in a row, so the assembled page would stay
|
|
@@ -24,7 +29,10 @@ export function esc(s) {
|
|
|
24
29
|
export function stripModules(src) {
|
|
25
30
|
return src.split('\n')
|
|
26
31
|
.filter((line) => !/^import\s.*;\s*$/.test(line))
|
|
27
|
-
|
|
32
|
+
/* `async` is part of the form rather than a decoration of it: a declaration marked `export async` is as ordinary
|
|
33
|
+
* a source as the rest, and the pasted page has no module syntax to resolve either of them with. */
|
|
34
|
+
.map((line) => line.replace(/^export\s+(?:async\s+)?(?:function|const|let|var|class)\s/,
|
|
35
|
+
(head) => head.slice('export '.length)))
|
|
28
36
|
.join('\n');
|
|
29
37
|
}
|
|
30
38
|
|
|
@@ -33,11 +41,32 @@ export function pageSource(file) {
|
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
/* The list of chapters lives here rather than in the tests: one copy for the builder and for the guard
|
|
36
|
-
* (`test/page-view.test.js` reads the same program and compares it with the sources).
|
|
37
|
-
|
|
44
|
+
* (`test/page-view.test.js` reads the same program and compares it with the sources). The payload chapter comes
|
|
45
|
+
* first of the page's own: it is what turns the block into the data everything else reads. */
|
|
46
|
+
export const PAGE_PARTS = ['./payload.js', './state.js', './dom.js', './panel.js', './table.js', './app.js'];
|
|
47
|
+
|
|
48
|
+
/* The form the artifact carries: the same stripping the `min` metric counts (`src/strip.js`) — comments out,
|
|
49
|
+
* indentation and blank lines out — applied to what is pasted, while the sources on disk keep everything: they are
|
|
50
|
+
* read, edited and linted by people, and only the copy inside the report is squeezed. Exported because the checks
|
|
51
|
+
* compare the page with exactly this form rather than with the sources. */
|
|
52
|
+
export function squeezedJs(code) {
|
|
53
|
+
return stripLines(stripJs(code));
|
|
54
|
+
}
|
|
38
55
|
|
|
56
|
+
export function squeezedCss(css) {
|
|
57
|
+
return stripLines(stripCss(css));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* The page's program: the chapters glued, the module syntax stripped line by line, the text squeezed — in that
|
|
61
|
+
* order, because the stripping works line by line and a comment could otherwise hide a line's shape. The squeeze
|
|
62
|
+
* may throw away nothing but comments and air, so what comes out has to parse, and the same guard the `min` metric
|
|
63
|
+
* uses says so: with no revision (the text is built here and now) and without the unparsed original, because a text
|
|
64
|
+
* that does not parse at all is not "a damaged file" but a bug of this assembler. */
|
|
39
65
|
export function pageScript() {
|
|
40
|
-
|
|
66
|
+
const code = squeezedJs(pageSource('../derived.js') + '\n'
|
|
67
|
+
+ PAGE_PARTS.map((part) => pageSource(part)).join(''));
|
|
68
|
+
assertCompilable(code, '', 'the page’s program');
|
|
69
|
+
return code;
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
/* The note under the heading: what built the report and where it lies. The path is plain text rather than a link: the
|
|
@@ -67,11 +96,9 @@ function uiText(page, loc) {
|
|
|
67
96
|
linkForeign: page.linkForeign,
|
|
68
97
|
linkBroken: page.linkBroken,
|
|
69
98
|
linkExtra: page.linkExtra,
|
|
70
|
-
/*
|
|
71
|
-
*
|
|
72
|
-
|
|
73
|
-
approximate: page.approximate,
|
|
74
|
-
approxCell: page.approximateCell,
|
|
99
|
+
/* What a host that cannot unpack the block is told: the page's one message about its own file rather than about
|
|
100
|
+
* the report's numbers (see `appBegin`). */
|
|
101
|
+
unpack: page.unpack,
|
|
75
102
|
/* Why a file is not in the report, in words: the engine names the reason with a mark (`why`), and the page dresses
|
|
76
103
|
* the mark in text, as it does with everything else in the panel. */
|
|
77
104
|
notMeasuredRule: page.notMeasuredRule,
|
|
@@ -91,19 +118,133 @@ function uiText(page, loc) {
|
|
|
91
118
|
};
|
|
92
119
|
}
|
|
93
120
|
|
|
94
|
-
/*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
|
|
121
|
+
/* The page's block is the contract in **sparse form** (`schema: 2`): the history as changes rather than as a snapshot
|
|
122
|
+
* per commit, the texts in a dictionary of their own, and the rows' links cut by the part they share. The dense form
|
|
123
|
+
* stays what `--data` answers with — an agent reads the contract as it is — and the page's own chapter unrolls this
|
|
124
|
+
* one (`src/page/payload.js`, `appDecode`), so the two places it is read are the encoder here and the decoder there.
|
|
125
|
+
* `test/contract-data.test.js` holds the round trip between them.
|
|
126
|
+
*
|
|
127
|
+
* What the block leaves out besides the sparse form is the list of skipped commits: it changes with the report's own
|
|
128
|
+
* commit (one with nothing to say lands in the list), and the file would stop being a **fixed point** — a rebuild
|
|
129
|
+
* after its own commit would yield different bytes and the hook would commit the report forever. The page has no use
|
|
130
|
+
* for the list at all: it does not show it. It stays available to the reader — `--data`, `--json` and `explain`
|
|
131
|
+
* answer from the same run.
|
|
132
|
+
*
|
|
133
|
+
* How much this is worth: the artifact of this repository carried 1 370 627 B of data as a snapshot per commit — 95 %
|
|
134
|
+
* of the whole file — while nine tenths of the cells repeat the row above; the same history as changes is about 84 000
|
|
135
|
+
* B. Reproducibility is untouched: the artifact stays a fixed point, rebuilt byte for byte after its own commit. */
|
|
136
|
+
|
|
137
|
+
/* The block's fields, in the order the encoder writes them. The shape is closed: a field added here has to be read in
|
|
138
|
+
* the decoder, and the page's checks compare the block with this list rather than with a description of it. */
|
|
139
|
+
export const PAGE_KEYS = ['schema', 'tool', 'report', 'hrefPrefix', 'strs', 'metrics', 'cats', 'files',
|
|
140
|
+
'catalog', 'rows', 'last', 'hist'];
|
|
141
|
+
|
|
142
|
+
/* The texts of the block, each written once. The dictionary is extended in the order of the walk `pagePayload` makes
|
|
143
|
+
* — files in the column order, rows in the history order — and the order of the first appearance is what decides an
|
|
144
|
+
* index. That is part of the format rather than a detail: the artifact is rebuilt after every commit and has to come
|
|
145
|
+
* out byte-identical on any machine. */
|
|
146
|
+
function dictionary() {
|
|
147
|
+
const list = [];
|
|
148
|
+
const at = new Map();
|
|
149
|
+
function of(text) {
|
|
150
|
+
if (text === null) return null;
|
|
151
|
+
const seen = at.get(text);
|
|
152
|
+
if (seen !== undefined) return seen;
|
|
153
|
+
list.push(text);
|
|
154
|
+
at.set(text, list.length - 1);
|
|
155
|
+
return list.length - 1;
|
|
156
|
+
}
|
|
157
|
+
return { list: list, of: of };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* What the rows' links have in common: for an ordinary row the commit template with the sha cut out, and, when some
|
|
161
|
+
* rows lead to a journal section instead, the part the two kinds share. A row keeps only the rest of its link — which
|
|
162
|
+
* for an ordinary row is the sha the row carries anyway, so a link costs nothing beyond the prefix written once. */
|
|
163
|
+
function linkPrefix(rows) {
|
|
164
|
+
const links = rows.map((r) => r.href).filter((href) => href !== null);
|
|
165
|
+
let out = links.length === 0 ? null : links[0];
|
|
166
|
+
links.forEach((href) => {
|
|
167
|
+
let n = 0;
|
|
168
|
+
while (n < out.length && href[n] === out[n]) n++;
|
|
169
|
+
out = out.slice(0, n);
|
|
170
|
+
});
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* One file between two commits: nothing when the numbers are the same, the absolute numbers when the file was absent
|
|
175
|
+
* before, the deltas when it moved, and a record of the row alone when it is gone — the three shapes the decoder
|
|
176
|
+
* reads. A commit that did not touch a file has no record for it, which is what makes the block small: the numbers are
|
|
177
|
+
* compared rather than the list of paths a commit changed. */
|
|
178
|
+
function change(was, nums) {
|
|
179
|
+
if (nums === null) return was === null ? null : [];
|
|
180
|
+
if (was === null) return nums;
|
|
181
|
+
const deltas = nums.map((n, mi) => n - was[mi]);
|
|
182
|
+
return deltas.every((d) => d === 0) ? null : deltas;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The history of every file, in the column order: the rows it appeared in, moved in and disappeared in.
|
|
186
|
+
function history(keys, files, rows) {
|
|
187
|
+
const was = files.map(() => null);
|
|
188
|
+
const hist = files.map(() => []);
|
|
189
|
+
rows.forEach((row, r) => {
|
|
190
|
+
row.values.forEach((v, i) => {
|
|
191
|
+
const nums = v === null ? null : keys.map((key) => v[key]);
|
|
192
|
+
const rec = change(was[i], nums);
|
|
193
|
+
if (rec !== null) hist[i].push([r].concat(rec));
|
|
194
|
+
was[i] = nums;
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
return hist;
|
|
198
|
+
}
|
|
100
199
|
|
|
101
200
|
export function pagePayload(data) {
|
|
102
|
-
const
|
|
103
|
-
|
|
201
|
+
const keys = data.metrics.map((m) => m.key);
|
|
202
|
+
const dict = dictionary();
|
|
203
|
+
const prefix = linkPrefix(data.rows);
|
|
204
|
+
const out = {
|
|
205
|
+
schema: 2,
|
|
206
|
+
tool: data.tool,
|
|
207
|
+
/* The report's own words are the ones the page reads: `heading` is the artifact's `<h1>` and `journal` is null
|
|
208
|
+
* today, so neither is carried — the page builds no heading and prints no journal. */
|
|
209
|
+
report: {
|
|
210
|
+
locale: data.report.locale,
|
|
211
|
+
title: data.report.title,
|
|
212
|
+
artifact: data.report.artifact,
|
|
213
|
+
fixCommand: data.report.fixCommand,
|
|
214
|
+
showSha: data.report.showSha
|
|
215
|
+
},
|
|
216
|
+
hrefPrefix: prefix,
|
|
217
|
+
strs: dict.list,
|
|
218
|
+
metrics: data.metrics.map((m) => [dict.of(m.key), dict.of(m.label), dict.of(m.note), dict.of(m.method)]),
|
|
219
|
+
cats: data.categories.map((c) => [dict.of(c.key), dict.of(c.label)]),
|
|
220
|
+
files: data.files.map((f) => [dict.of(f.label), dict.of(f.path),
|
|
221
|
+
f.paths.map((p) => dict.of(p)), dict.of(f.category), dict.of(f.categoryBy)]),
|
|
222
|
+
catalog: data.catalog.map((e) => [dict.of(e.path), dict.of(e.why)]),
|
|
223
|
+
rows: data.rows.map((r) => [dict.of(r.sha), dict.of(r.when), dict.of(r.subject),
|
|
224
|
+
r.section === null ? null : dict.of(r.section.id),
|
|
225
|
+
r.section === null ? null : dict.of(r.section.head),
|
|
226
|
+
r.section !== null && r.section.added ? 1 : 0,
|
|
227
|
+
r.href === null ? null : dict.of(r.href.slice(prefix.length))]),
|
|
228
|
+
last: [],
|
|
229
|
+
hist: history(keys, data.files, data.rows)
|
|
230
|
+
};
|
|
231
|
+
data.last.forEach((on, i) => { if (on) out.last.push(i); });
|
|
104
232
|
return out;
|
|
105
233
|
}
|
|
106
234
|
|
|
235
|
+
/* The block as the artifact carries it: gzip of its JSON, base64. The packing is a **transport, not the shape** —
|
|
236
|
+
* what the page unpacks is the very block it received before (`schema: 2`), and `--data`/`--json` keep answering
|
|
237
|
+
* with the dense contract. It is here because the block is most of the file: this repository's report carried
|
|
238
|
+
* 88 786 B of it against 32 142 B compressed, 42 856 B with base64's third.
|
|
239
|
+
*
|
|
240
|
+
* Level 9 because the block is written once per commit and read by whoever opens the file, not by a server under
|
|
241
|
+
* load. What the fixed point rests on is that the bytes are reproducible: zlib writes no time into the gzip header,
|
|
242
|
+
* so the same text gives the same bytes (the version of zlib is the machine's, like the version of git). Base64 is
|
|
243
|
+
* what makes the bytes survive a text file — a gzip stream is not valid UTF-8, and the report is one. */
|
|
244
|
+
export function pagePacked(text) {
|
|
245
|
+
return zlib.gzipSync(Buffer.from(text, 'utf8'), { level: 9 }).toString('base64');
|
|
246
|
+
}
|
|
247
|
+
|
|
107
248
|
/* The report's page is one file: the data lies in it, the script is pasted in, there are no external references. Hence
|
|
108
249
|
* it opens with a double click and works without a network. */
|
|
109
250
|
export function pageHtml(data, cfg) {
|
|
@@ -111,7 +252,7 @@ export function pageHtml(data, cfg) {
|
|
|
111
252
|
return '<!doctype html>\n<html lang="' + esc(loc.html) + '">\n<head>\n<meta charset="utf-8">\n'
|
|
112
253
|
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
|
113
254
|
+ '<title>' + esc(data.report.title) + '</title>\n<style>\n'
|
|
114
|
-
+ TABLE_CSS + '\n' + PAGE_CSS + '\n</style>\n</head>\n<body>\n'
|
|
255
|
+
+ squeezedCss(TABLE_CSS) + '\n' + squeezedCss(PAGE_CSS) + '\n</style>\n</head>\n<body>\n'
|
|
115
256
|
+ '<header>\n<h1>' + esc(data.report.heading) + '</h1>\n'
|
|
116
257
|
+ '<p class="sub">' + esc(subText(data, loc.page)) + '</p>\n</header>\n'
|
|
117
258
|
+ '<div id="panel" class="panel"></div>\n'
|
|
@@ -119,7 +260,8 @@ export function pageHtml(data, cfg) {
|
|
|
119
260
|
+ '<div id="shell" class="shell"><table id="grid"></table></div>\n'
|
|
120
261
|
+ '<p id="state" class="state" hidden></p>\n'
|
|
121
262
|
+ '<p id="note" class="note"></p>\n'
|
|
122
|
-
+ '<script type="application/
|
|
263
|
+
+ '<script type="application/octet-stream" id="data" data-pack="base64+gzip">'
|
|
264
|
+
+ pagePacked(jsonInHtml(pagePayload(data))) + '</script>\n'
|
|
123
265
|
+ '<script type="application/json" id="ui">' + jsonInHtml(uiText(loc.page, loc)) + '</script>\n'
|
|
124
266
|
+ '<script>\n' + pageScript() + '</script>\n</body>\n</html>\n';
|
|
125
267
|
}
|
package/src/page/panel.js
CHANGED
|
@@ -7,12 +7,11 @@ import { appData, appUi, appView, appFileAt, appFoldSet, appMeasured } from './s
|
|
|
7
7
|
function appFileBox(i) {
|
|
8
8
|
const f = appData.files[i];
|
|
9
9
|
const where = appFileAt(i) + (f.path === null ? appUi.notOnHead : '');
|
|
10
|
-
|
|
10
|
+
const box = appBox(f.label, where + appUi.category
|
|
11
11
|
+ (f.categoryBy === 'config' ? appUi.categoryFromConfig : appUi.categoryByExtension),
|
|
12
|
-
appView.files[i], (e) =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
});
|
|
12
|
+
appView.files[i], (e) => appSwitch(i, e.target.checked));
|
|
13
|
+
appFields.file[i] = box.querySelector('input');
|
|
14
|
+
return box;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
/* A file that is not in the report: it stands in its place in the tree with its checkbox off and unavailable — no
|
|
@@ -48,7 +47,7 @@ function appNode() {
|
|
|
48
47
|
* a fraction ("2/5"): what matters to the reader is that the folder holds five files while two are measured. A folder
|
|
49
48
|
* without a single measured file stays in place with its checkbox off and unavailable: there is nothing to switch on
|
|
50
49
|
* in it. */
|
|
51
|
-
function appDirHead(name, sub) {
|
|
50
|
+
function appDirHead(name, here, sub) {
|
|
52
51
|
const idx = appIndexes(sub);
|
|
53
52
|
const total = appCount(sub);
|
|
54
53
|
const label = name + '/';
|
|
@@ -59,11 +58,9 @@ function appDirHead(name, sub) {
|
|
|
59
58
|
const on = idx.map((i) => appView.files[i]);
|
|
60
59
|
const every = on.every((v) => v);
|
|
61
60
|
head = appBox(label, appUi.dir.replace('{name}', name).replace('{n}', idx.length),
|
|
62
|
-
every, (e) =>
|
|
63
|
-
idx.forEach((i) => { appView.files[i] = e.target.checked; });
|
|
64
|
-
appRender();
|
|
65
|
-
}, 'dir');
|
|
61
|
+
every, (e) => appSwitchGroup(idx, e.target.checked), 'dir');
|
|
66
62
|
head.querySelector('input').indeterminate = !every && on.some((v) => v);
|
|
63
|
+
appFields.dir[here] = head;
|
|
67
64
|
}
|
|
68
65
|
head.appendChild(appEl('span', 'n', idx.length === total ? String(total) : idx.length + '/' + total));
|
|
69
66
|
return head;
|
|
@@ -111,7 +108,7 @@ function appDir(name, sub, prefix) {
|
|
|
111
108
|
const folded = appView.folded[here] === true;
|
|
112
109
|
const li = appEl('li', folded ? 'folded' : null);
|
|
113
110
|
li.appendChild(appFoldBox(name, here));
|
|
114
|
-
li.appendChild(appDirHead(name, sub));
|
|
111
|
+
li.appendChild(appDirHead(name, here, sub));
|
|
115
112
|
li.appendChild(appTreeList(sub, here));
|
|
116
113
|
return li;
|
|
117
114
|
}
|
|
@@ -165,6 +162,10 @@ function appTree() {
|
|
|
165
162
|
return appTreeList(root, '');
|
|
166
163
|
}
|
|
167
164
|
|
|
165
|
+
/* The panel: built once, at the first drawing, and afterwards only its fields change. A rebuild would count the whole
|
|
166
|
+
* table for nothing — the tree, the counters and the tooltips are the same after every click — while it would also
|
|
167
|
+
* take the reader's place in the list away: the scroll of the panel and of the tree, and the field under the
|
|
168
|
+
* keyboard, would have to be put back by hand. Nothing of that is here, because there is nothing to put back. */
|
|
168
169
|
export function appPanel() {
|
|
169
170
|
const panel = document.getElementById('panel');
|
|
170
171
|
panel.textContent = '';
|
|
@@ -173,11 +174,12 @@ export function appPanel() {
|
|
|
173
174
|
metrics.appendChild(appEl('legend', null, appUi.metrics));
|
|
174
175
|
const mrow = appEl('div', 'row');
|
|
175
176
|
appData.metrics.forEach((m) => {
|
|
176
|
-
const
|
|
177
|
-
mrow.appendChild(appBox(m.label, m.note + ' · ' + word, appView.metrics[m.key], (e) => {
|
|
177
|
+
const box = appBox(m.label, m.note, appView.metrics[m.key], (e) => {
|
|
178
178
|
appView.metrics[m.key] = e.target.checked;
|
|
179
|
-
|
|
180
|
-
}, 'metric')
|
|
179
|
+
appSwitchMetric();
|
|
180
|
+
}, 'metric');
|
|
181
|
+
appFields.metric[m.key] = box.querySelector('input');
|
|
182
|
+
mrow.appendChild(box);
|
|
181
183
|
});
|
|
182
184
|
metrics.appendChild(mrow);
|
|
183
185
|
/* What produced each number is visible rather than hidden in a tooltip: the token dictionary and the way of
|
|
@@ -196,13 +198,102 @@ export function appPanel() {
|
|
|
196
198
|
appData.categories.forEach((cat) => {
|
|
197
199
|
const idx = [];
|
|
198
200
|
appData.files.forEach((f, i) => { if (f.category === cat.key) idx.push(i); });
|
|
199
|
-
|
|
200
|
-
(e) =>
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}, 'all'));
|
|
201
|
+
const box = appBox(cat.label, appUi.all + ' · ' + cat.label, idx.every((i) => appView.files[i]),
|
|
202
|
+
(e) => appSwitchGroup(idx, e.target.checked), 'all');
|
|
203
|
+
appFields.cat[cat.key] = box.querySelector('input');
|
|
204
|
+
cats.appendChild(box);
|
|
204
205
|
});
|
|
205
206
|
files.appendChild(cats);
|
|
206
207
|
files.appendChild(appTree());
|
|
207
208
|
panel.appendChild(files);
|
|
208
209
|
}
|
|
210
|
+
|
|
211
|
+
/* -------- the panel's fields after a choice -------- */
|
|
212
|
+
|
|
213
|
+
/* The fields of the panel by name: the markup is built once, so a click needs a reference to the field it changes
|
|
214
|
+
* rather than a rebuild of the panel. Only a file owns a state — a folder and a category are ways to set the same
|
|
215
|
+
* boxes — which is why their fields are read from the files below them rather than kept. */
|
|
216
|
+
const appFields = { metric: {}, file: {}, dir: {}, cat: {} };
|
|
217
|
+
|
|
218
|
+
/* The boxes of a row's own subtree, read from the tree itself: the panel keeps no second list of the files a folder
|
|
219
|
+
* holds, and what the reader sees is exactly the boxes that are here. A file outside the report stands in the tree
|
|
220
|
+
* with nothing to switch, hence it is left out. */
|
|
221
|
+
function appRowBoxes(box) {
|
|
222
|
+
return [...box.closest('li').querySelectorAll('.box:not(.dir):not(.plain) input')];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A folder's field from its files: all on — checked, some — the third state, none — simply unchecked.
|
|
226
|
+
function appDirState(box) {
|
|
227
|
+
const boxes = appRowBoxes(box);
|
|
228
|
+
const on = boxes.filter((b) => b.checked).length;
|
|
229
|
+
const input = box.querySelector('input');
|
|
230
|
+
input.checked = on === boxes.length;
|
|
231
|
+
input.indeterminate = on > 0 && on < boxes.length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* A category's field from its files — the same rule, taken from the data: a category's files are named by the
|
|
235
|
+
* category itself (`category`), and the boxes of the tree are a different view of the same files. */
|
|
236
|
+
function appCatState(key) {
|
|
237
|
+
let all = 0;
|
|
238
|
+
let on = 0;
|
|
239
|
+
appData.files.forEach((f, i) => {
|
|
240
|
+
if (f.category !== key) return;
|
|
241
|
+
all++;
|
|
242
|
+
if (appView.files[i] === true) on++;
|
|
243
|
+
});
|
|
244
|
+
const input = appFields.cat[key];
|
|
245
|
+
input.checked = on === all;
|
|
246
|
+
input.indeterminate = on > 0 && on < all;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* The folders a file lies in: the prefixes of its path, from the root down. The tree's folders are exactly those
|
|
250
|
+
* prefixes — that is how it is built (`appLeafAt`) — so no second naming rule is needed. */
|
|
251
|
+
function appDirPath(path) {
|
|
252
|
+
const parts = path.split('/');
|
|
253
|
+
return parts.slice(0, -1).map((_part, i) => parts.slice(0, i + 1).join('/'));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* A click reaches a folder's field through the files below it: every folder on the path of a switched file shows the
|
|
257
|
+
* share of what is left on. A folder without measured files has a field of its own too — it is off and unavailable,
|
|
258
|
+
* which is not the reader's state and must not be overwritten here. */
|
|
259
|
+
function appDirsOf(indexes) {
|
|
260
|
+
const seen = {};
|
|
261
|
+
indexes.forEach((i) => {
|
|
262
|
+
appDirPath(appFileAt(i)).forEach((path) => {
|
|
263
|
+
if (seen[path] === true) return;
|
|
264
|
+
seen[path] = true;
|
|
265
|
+
if (appFields.dir[path] !== undefined) appDirState(appFields.dir[path]);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The same for the quick buttons of the categories: every category one of the switched files belongs to.
|
|
271
|
+
function appCatsOf(indexes) {
|
|
272
|
+
const seen = {};
|
|
273
|
+
indexes.forEach((i) => {
|
|
274
|
+
const key = appData.files[i].category;
|
|
275
|
+
if (seen[key] === true || appFields.cat[key] === undefined) return;
|
|
276
|
+
seen[key] = true;
|
|
277
|
+
appCatState(key);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* What a click changed, written where it stands: the files' own boxes, then the fields of the folders and categories
|
|
282
|
+
* that hold them. Nothing is rebuilt, and no field the choice did not reach is touched. */
|
|
283
|
+
export function appPanelState(indexes) {
|
|
284
|
+
indexes.forEach((i) => {
|
|
285
|
+
const input = appFields.file[i];
|
|
286
|
+
if (input !== undefined) input.checked = appView.files[i] === true;
|
|
287
|
+
});
|
|
288
|
+
appDirsOf(indexes);
|
|
289
|
+
appCatsOf(indexes);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* The whole panel from the view: what a link, a record from the memory and the first drawing need. The metric boxes
|
|
293
|
+
* are the reader's own click otherwise, which is why they are not refreshed on a file's switch. */
|
|
294
|
+
export function appPanelAll() {
|
|
295
|
+
appData.categories.forEach((c) => appCatState(c.key));
|
|
296
|
+
appData.metrics.forEach((m) => { appFields.metric[m.key].checked = appView.metrics[m.key] === true; });
|
|
297
|
+
appData.files.forEach((_f, i) => { appFields.file[i].checked = appView.files[i] === true; });
|
|
298
|
+
Object.keys(appFields.dir).forEach((path) => appDirState(appFields.dir[path]));
|
|
299
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/* The page's block: how the file carries it (gzipped and base64 encoded, `appUnpack`) and how the block in
|
|
2
|
+
* sparse form is turned back into the model the page already spoke.
|
|
3
|
+
*
|
|
4
|
+
* The packing is a **transport rather than the shape**: what comes out of the unpacker is the very block the
|
|
5
|
+
* page received before this chapter knew about gzip, with no field added or removed, and `--data`/`--json`
|
|
6
|
+
* answer with the dense contract as they always did. It buys the artifact's weight (the block is the whole file)
|
|
7
|
+
* and pays for it in two ways, both on purpose: the block can no longer be read by eye or by `diff`, and
|
|
8
|
+
* unpacking is **asynchronous** — the platform's own `DecompressionStream` is the only unpacker here, no library
|
|
9
|
+
* travels in the page and nothing is fetched, so the first drawing waits for a promise where it used to happen
|
|
10
|
+
* during the parse.
|
|
11
|
+
*
|
|
12
|
+
* Why the block is sparse. Of 56 019 cells of this repository's report the non-empty ones hold 1 183 distinct
|
|
13
|
+
* triples, and about a thousand cells differ from the row above: nine tenths of the block is yesterday's
|
|
14
|
+
* numbers written again. So the block keeps, for every file, the rows in which it appeared (absolute numbers),
|
|
15
|
+
* moved (deltas against its own previous record) or disappeared, and this chapter puts the snapshots back
|
|
16
|
+
* together. The whole history walk is O(number of changes) rather than O(rows × files).
|
|
17
|
+
*
|
|
18
|
+
* The model after `appDecode` is exactly `--data`: the calculation (`rowModel`, `totalsOf`, `cellParts`,
|
|
19
|
+
* `valueParts`), the table and the panel know nothing about the sparse form, so there is no second way to
|
|
20
|
+
* count a row. A value that did not move is **one object shared by the rows that hold it** — the heap keeps
|
|
21
|
+
* the distinct numbers rather than a copy per commit — and because every consumer reads `v[metric]` and
|
|
22
|
+
* compares nothing by identity, sharing changes no answer.
|
|
23
|
+
*
|
|
24
|
+
* Two rules of the chapters bind here as well: no `import` lines and no module state (the text is pasted into
|
|
25
|
+
* one file), and nothing but declarations (the round-trip check evaluates this file on its own).
|
|
26
|
+
*
|
|
27
|
+
* The dictionary (`strs`) is walked by the encoder in a stated order — files in the column order, rows in the
|
|
28
|
+
* history order, and only the order of the first appearance decides an index. The artifact is rebuilt after
|
|
29
|
+
* every commit and has to come out byte-identical on any machine, so the order of the walk is part of the
|
|
30
|
+
* format rather than a detail of the encoder.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/* The block as the file carries it: the tag's `data-pack` says which packing it is, so the page asks rather than
|
|
34
|
+
* guesses, and a packing it does not know is an error rather than a half-read block. A tag without the marker
|
|
35
|
+
* carries the block itself — the same page works for a build that packs nothing.
|
|
36
|
+
*
|
|
37
|
+
* `DecompressionStream` answers with streams rather than with bytes, hence the reader loop: the promise this
|
|
38
|
+
* function is *is* the price of the weight (see the file's note). `atob` gives one character per byte, and every
|
|
39
|
+
* character above 127 has to be taken back as a byte (`charCodeAt`) rather than as text; `TextDecoder` turns the
|
|
40
|
+
* inflated bytes into the JSON text, which is UTF-8 with the report's own words in it. */
|
|
41
|
+
export async function appUnpack(el) {
|
|
42
|
+
const pack = el.getAttribute('data-pack');
|
|
43
|
+
const text = el.textContent;
|
|
44
|
+
if (pack === null) return text;
|
|
45
|
+
if (pack !== 'base64+gzip') throw new Error('the page’s data is packed as “' + pack + '”, which this page cannot read');
|
|
46
|
+
const stream = new DecompressionStream('gzip');
|
|
47
|
+
const sink = stream.writable.getWriter();
|
|
48
|
+
/* The writing is not waited for before the reading: a stream that is filled before it is drained would stall on
|
|
49
|
+
* its own backpressure. A failure of the write surfaces in the reading loop, which is what this promise returns. */
|
|
50
|
+
const feeding = sink.write(appBytes(text)).then(() => sink.close()).catch(() => null);
|
|
51
|
+
const source = stream.readable.getReader();
|
|
52
|
+
const parts = [];
|
|
53
|
+
for (;;) {
|
|
54
|
+
const step = await source.read();
|
|
55
|
+
if (step.done) break;
|
|
56
|
+
parts.push(step.value);
|
|
57
|
+
}
|
|
58
|
+
await feeding;
|
|
59
|
+
return new TextDecoder().decode(appJoined(parts));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// base64 read as bytes: the browser's `atob` hands out one character per byte, whatever the byte.
|
|
63
|
+
function appBytes(text) {
|
|
64
|
+
const raw = atob(text);
|
|
65
|
+
const bytes = new Uint8Array(raw.length);
|
|
66
|
+
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
|
67
|
+
return bytes;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* The chunks of an inflated stream as one array: a chunk boundary falls wherever the platform put it. The length is
|
|
71
|
+
* summed by hand rather than by `reduce` — the page's shell computes no totals of its own, and this chapter is part
|
|
72
|
+
* of the shell the checks read (`test/page-view.test.js`). */
|
|
73
|
+
function appJoined(parts) {
|
|
74
|
+
let size = 0;
|
|
75
|
+
parts.forEach((part) => { size += part.length; });
|
|
76
|
+
const all = new Uint8Array(size);
|
|
77
|
+
let at = 0;
|
|
78
|
+
parts.forEach((part) => { all.set(part, at); at += part.length; });
|
|
79
|
+
return all;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The dictionary's entry: a text the block does not carry stays absent rather than becoming an empty string.
|
|
83
|
+
function appText(p, i) {
|
|
84
|
+
return i === null ? null : p.strs[i];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A value as the page reads it: the metrics by their keys, in the order the block lists them.
|
|
88
|
+
function appValue(keys, nums) {
|
|
89
|
+
const out = {};
|
|
90
|
+
keys.forEach((key, mi) => { out[key] = nums[mi]; });
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* One record of a file's history applied to what the file was: the numbers are absolute when the file was
|
|
95
|
+
* absent and deltas against its own previous record otherwise. A record with nothing but a row means the file
|
|
96
|
+
* is gone from that revision, which is the same `null` the dense contract carries. What comes out is the
|
|
97
|
+
* object every later row shares until the file moves again. */
|
|
98
|
+
function appStep(keys, was, rec) {
|
|
99
|
+
if (rec.length === 1) return null;
|
|
100
|
+
const nums = rec.slice(1);
|
|
101
|
+
if (was === null) return appValue(keys, nums);
|
|
102
|
+
return appValue(keys, nums.map((d, mi) => was[keys[mi]] + d));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* The history unrolled, once, into a snapshot per commit — the shape the contract hands out. The records of
|
|
106
|
+
* every file are consumed in the order of the rows, and each row takes what the files were at it: a file whose
|
|
107
|
+
* record has not come around is the very object it was in the row above.
|
|
108
|
+
*
|
|
109
|
+
* The tail of the walk is "now": the report's last row is its last commit that moved a number, the commits
|
|
110
|
+
* after it moved none, and what the files were at the end is the state at HEAD — which is what the dense
|
|
111
|
+
* `now` is. */
|
|
112
|
+
function appUnroll(p, keys) {
|
|
113
|
+
const at = p.files.map(() => 0);
|
|
114
|
+
const live = p.files.map(() => null);
|
|
115
|
+
const rows = [];
|
|
116
|
+
for (let r = 0; r < p.rows.length; r++) {
|
|
117
|
+
const row = [];
|
|
118
|
+
p.files.forEach((_f, i) => {
|
|
119
|
+
const rec = p.hist[i][at[i]];
|
|
120
|
+
if (rec !== undefined && rec[0] === r) {
|
|
121
|
+
live[i] = appStep(keys, live[i], rec);
|
|
122
|
+
at[i]++;
|
|
123
|
+
}
|
|
124
|
+
row.push(live[i]);
|
|
125
|
+
});
|
|
126
|
+
rows.push(row);
|
|
127
|
+
}
|
|
128
|
+
return { rows: rows, now: live.slice() };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* A commit row: the caption and where it leads. The address is the block's one prefix plus what the row kept
|
|
132
|
+
* of its own link with the sha, and `added` travels as a mark rather than as a word. The name is not `appRow`:
|
|
133
|
+
* the chapters are pasted into **one scope**, where the table's own `appRow` would quietly win and this one
|
|
134
|
+
* would never be called (`test/page-view.test.js` holds the names apart for that very reason). */
|
|
135
|
+
function appRowOf(p, r, values) {
|
|
136
|
+
const section = r[3] === null ? null : { id: appText(p, r[3]), head: appText(p, r[4]), added: r[5] === 1 };
|
|
137
|
+
return {
|
|
138
|
+
sha: appText(p, r[0]),
|
|
139
|
+
when: appText(p, r[1]),
|
|
140
|
+
subject: appText(p, r[2]),
|
|
141
|
+
section: section,
|
|
142
|
+
href: r[6] === null ? null : p.hrefPrefix + appText(p, r[6]),
|
|
143
|
+
values: values
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* The whole block, unrolled into the dense contract the page reads: `schema: 2` is refused by `appRecordOk`
|
|
148
|
+
* rather than unrolled — a record written for a block of another form describes another choice. */
|
|
149
|
+
export function appDecode(p) {
|
|
150
|
+
const keys = p.metrics.map((m) => appText(p, m[0]));
|
|
151
|
+
const hist = appUnroll(p, keys);
|
|
152
|
+
const last = p.files.map(() => false);
|
|
153
|
+
p.last.forEach((i) => { last[i] = true; });
|
|
154
|
+
return {
|
|
155
|
+
schema: p.schema,
|
|
156
|
+
tool: p.tool,
|
|
157
|
+
report: p.report,
|
|
158
|
+
metrics: p.metrics.map((m) => ({ key: appText(p, m[0]), label: appText(p, m[1]),
|
|
159
|
+
note: appText(p, m[2]), method: appText(p, m[3]) })),
|
|
160
|
+
categories: p.cats.map((c) => ({ key: appText(p, c[0]), label: appText(p, c[1]) })),
|
|
161
|
+
files: p.files.map((f) => ({ label: appText(p, f[0]), path: appText(p, f[1]),
|
|
162
|
+
paths: f[2].map((i) => p.strs[i]), category: appText(p, f[3]), categoryBy: appText(p, f[4]) })),
|
|
163
|
+
catalog: p.catalog.map((e) => ({ path: appText(p, e[0]), why: appText(p, e[1]) })),
|
|
164
|
+
rows: p.rows.map((r, ri) => appRowOf(p, r, hist.rows[ri])),
|
|
165
|
+
now: hist.now,
|
|
166
|
+
last: last
|
|
167
|
+
};
|
|
168
|
+
}
|