@vernikr/size-report 2.5.0 → 2.7.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/src/page/app.js CHANGED
@@ -1,101 +1,169 @@
1
- import { appData, appUi, appView, appWrite, appNotice, appLinkUse, appRead, appApply, appFoldRead } from './state.js';
2
- import { appBody, appHead, appState } from './table.js';
3
- import { appPanel } from './panel.js';
1
+ import { appUnpack } from './payload.js';
2
+ import { appApply, appBoot, appData, appFoldRead, appLinkUse, appNotice, appRead, appUi, appView, appWrite } from './state.js';
3
+ import { appColumn, appColumnSize, appColumnStale, appContribute, appMetrics, appState, appTable, appTotals, appTotalsReset } from './table.js';
4
+ import { appDraw } from './work.js';
5
+ import { appPanel, appPanelAll, appPanelState } from './panel.js';
4
6
 
5
- /* Assembling the table: what to show (the metrics and files the reader left on) and where to put it. The table
6
- * chapter builds the markup of the head and the rows, the shared calculation gives the numbers all that is
7
- * left here is the decision and the insertion, with no numbers of its own. */
8
- function appTable() {
9
- const shown = appData.metrics.filter((m) => appView.metrics[m.key]);
10
- const metrics = shown.map((m) => m.key);
11
- const on = appView.files;
12
- const files = [];
13
- appData.files.forEach((f, i) => { if (on[i]) files.push(i); });
14
- /* The columns the last commit touched come first: the report is rebuilt after every commit, and a reader's first
15
- * question is what that edit brought. Inside each part the order stays as it comes from the settings `sort` is
16
- * stable, and the order of the columns is what the reader is used to. The mark comes from the history (which the
17
- * engine knows) rather than from the numbers: an edit that changed no size is an edit too. */
18
- files.sort((a, b) => (appData.last[a] === true ? 0 : 1) - (appData.last[b] === true ? 0 : 1));
7
+ /* Assembling the report: the table is built once (`appTable` of the table chapter) and everything afterwards only
8
+ * shows, hides and recounts. A click on any switch therefore costs a class, a number and the fields it reached the
9
+ * whole table used to be destroyed and built again, which was 81 % of the cost of a click and produced a hundred
10
+ * thousand dead nodes for the collector to walk.
11
+ *
12
+ * Hence two paths and no third: `appPaint` draws the whole view (the first drawing, a record from the browser's
13
+ * memory, a link in the address), while `appSwitch`, `appSwitchGroup` and `appSwitchMetric` are what one click on a
14
+ * box does. The numbers are counted on the click itself — that is arithmetic over the data — while the nodes of the
15
+ * columns are handed to the work chapter (`appDraw`), which draws the short work on the click and the long one in the
16
+ * next task with a stripe over the page (`src/page/work.js` says what was measured: the price of a switch is the
17
+ * browser's own relayout of the table, and it is paid once here rather than once per slice). Neither path makes a
18
+ * node.
19
+ */
19
20
 
20
- const table = document.getElementById('grid');
21
- table.textContent = '';
22
- appState(metrics.length, files.length);
23
- if (metrics.length === 0) return;
21
+ // The table's cache of node references: made once, at the first drawing.
22
+ let appCache = null;
24
23
 
25
- table.appendChild(appHead(shown, files, metrics));
26
- table.appendChild(appBody(metrics, files));
24
+ /* One file's column drawn from the view — the state is read when the slice runs rather than kept from the click that
25
+ * queued it, so a click that arrives while the queue is running is drawn by the next slice rather than after it. */
26
+ function appDrawColumn(i) {
27
+ appColumn(appCache, i, appView.files[i] === true);
28
+ }
29
+
30
+ /* The columns of a switch that have to be drawn, as the units the work chapter weighs (`{i, units}` — the file and the
31
+ * nodes of its column). Only the columns whose nodes are out of step with the view are asked for (`appColumnStale`): a
32
+ * report opened with everything switched on has nothing to draw, and a column that is already right would cost its
33
+ * nodes again. The count is the table's (`appColumnSize`), because the table is the only place that knows how many
34
+ * nodes a column has. */
35
+ function appColumns(indexes) {
36
+ const todo = indexes.filter((i) => appColumnStale(appCache, i));
37
+ appDraw(appDrawColumn, todo.map((i) => ({ i: i, units: appColumnSize(appCache, i) })));
38
+ }
39
+
40
+ /* The note under the table: what a row is and how the report was made. It does not depend on the choice, so it is
41
+ * written once — with the table rather than with every drawing of it. */
42
+ function appNote() {
27
43
  document.getElementById('note').textContent = appUi.note
28
44
  .replace('{rows}', appData.rows.length)
29
45
  .replace('{command}', appData.report.fixCommand);
46
+ }
47
+
48
+ /* What the empty states are told: how many metrics and how many files are left. The table stands there in either
49
+ * case (it is built once) — the words are about what is shown. */
50
+ export function appCounts() {
51
+ appState(appData.metrics.filter((m) => appView.metrics[m.key] === true).length,
52
+ appView.files.filter((on) => on === true).length);
53
+ }
54
+
55
+ /* The whole view drawn: every column, the totals of the whole selection, the metrics, the empty states and the panel's
56
+ * fields. This is what a link, a record from the memory and the first drawing need — and it makes no node either. */
57
+ export function appPaint() {
58
+ appColumns(appData.files.map((_f, i) => i));
59
+ appTotalsReset(appCache);
60
+ appMetrics(appCache);
61
+ appCounts();
62
+ appPanelAll();
30
63
  appWrite();
31
64
  }
32
65
 
33
- /* The panel's scroll is a property of the panel rather than of the markup, which is why it survives a rebuild:
34
- * otherwise every click on a checkbox would send the list back to the top and the files at its end would be
35
- * unreachable. Both the panel's scroll and the file list's are remembered — each has one of its own, and in a
36
- * narrow window it is the list that scrolls. The elements are the ones the page really has (`#panel` from the
37
- * markup, `.files` inside it from the panel): there is no second list of scroll places in the package. */
38
- const appScrolled = ['#panel', '#panel .files'];
39
- function appScrollTop() {
40
- return appScrolled.map((sel) => {
41
- const el = document.querySelector(sel);
42
- return el === null ? 0 : el.scrollTop;
43
- });
66
+ /* One file switched by the reader: the view, its share of the totals, the fields it shows in and its column — the
67
+ * last through the queue, because a column may be long. The message about a link fades here: by this action the
68
+ * reader has read it. */
69
+ export function appSwitch(i, on) {
70
+ if (appView.files[i] === on) return;
71
+ appView.files[i] = on;
72
+ appContribute(appCache, i, on);
73
+ appTotals(appCache);
74
+ appCounts();
75
+ appPanelState([i]);
76
+ appWrite();
77
+ appNotice('');
78
+ appColumns([i]);
44
79
  }
45
80
 
46
- function appScrollBack(saved) {
47
- appScrolled.forEach((sel, i) => {
48
- const el = document.querySelector(sel);
49
- if (el !== null) el.scrollTop = saved[i];
81
+ /* A group switched at once — a folder or a category: the same work per file, then the totals once and the fields of
82
+ * the files the choice really reached (switching a folder on when a part of it was already on touches only the rest,
83
+ * and a field that did not move is not written). The columns of the whole group go into the queue together, so the bar
84
+ * counts them as one piece of work. */
85
+ export function appSwitchGroup(indexes, on) {
86
+ const touched = indexes.filter((i) => appView.files[i] !== on);
87
+ touched.forEach((i) => {
88
+ appView.files[i] = on;
89
+ appContribute(appCache, i, on);
50
90
  });
91
+ appTotals(appCache);
92
+ appCounts();
93
+ appPanelState(touched);
94
+ appWrite();
95
+ appNotice('');
96
+ appColumns(touched);
97
+ }
98
+
99
+ /* One metric switched: a class on the table and the headings' `colSpan`. The totals do not move with a metric — they
100
+ * are sums over files — and the metric's own field is the box the reader just clicked. */
101
+ export function appSwitchMetric() {
102
+ appMetrics(appCache);
103
+ appCounts();
104
+ appWrite();
105
+ appNotice('');
51
106
  }
52
107
 
53
- /* The panel is redrawn whole, so the field under the keyboard and the scroll come back to their places after every
54
- * rebuild: otherwise switching with Tab and Space would mean walking the panel from the start again, and the scroll
55
- * would have to find its place anew. A field is identified by its ordinal number — the order of the panel's fields
56
- * does not change between rebuilds. The focus is set without scrolling (`preventScroll`): it returns the keyboard
57
- * rather than moving the list. */
58
- function appRender(keepNotice) {
59
- const at = Array.from(document.querySelectorAll('#panel input')).indexOf(document.activeElement);
60
- const saved = appScrollTop();
108
+ /* The first drawing: the choice is already in the view (the link and the memory are applied above), the panel is
109
+ * built to match it, the table is built once every column of every file and the view is painted over it. */
110
+ function appFirst() {
61
111
  appPanel();
62
- appScrollBack(saved);
63
- if (at >= 0) document.querySelectorAll('#panel input')[at].focus({ preventScroll: true });
64
- appTable();
65
- /* The message about the link survives the very drawing it caused, and fades on the reader's next action: he has read
66
- * it by then. */
67
- if (keepNotice !== true) appNotice('');
112
+ appCache = appTable(document.getElementById('grid'));
113
+ appNote();
114
+ appPaint();
68
115
  }
69
116
 
117
+ /* The page's one asynchronous step, and why there is one. The block in the artifact is packed, and the platform's own
118
+ * unpacker answers with a promise, so the first drawing waits for it; everything after the first drawing is as
119
+ * synchronous as it was, and a click costs what it cost. A host that cannot unpack is told in words instead of being
120
+ * left with an empty table — the reader would not know whether the report or the browser is at fault. */
121
+ let appBooted = false;
122
+
70
123
  /* Restoring happens before the first drawing: for someone opening the page for the first time the view has to be the
71
124
  * default rather than someone else's choice. A link outranks the memory: it is the sender's explicit choice, and
72
125
  * while the reader has changed nothing it does not replace his own — writing it to the memory is what does not
73
126
  * happen. A refused link is not an empty table but a message: the reader sees both what happened and what is shown
74
127
  * instead. */
75
- const appStart = appLinkUse();
76
- if (appStart === 'ours') appTransient = true;
77
- else if (appStart === 'refused') appForeign = true;
78
- if (appStart !== 'ours') {
79
- const appSaved = appRead();
80
- if (appSaved !== null) appApply(appSaved);
128
+ async function appBegin() {
129
+ try {
130
+ appBoot(await appUnpack(document.getElementById('data')));
131
+ } catch (_e) {
132
+ appNotice(appUi.unpack);
133
+ return;
134
+ }
135
+ const appStart = appLinkUse();
136
+ /* A link that came in is the sender's choice rather than the reader's: while it is drawn, the memory is not touched
137
+ * (`appWrite`), so opening a link does not make it the reader's own. */
138
+ appTransient = appStart === 'ours';
139
+ if (appStart !== 'ours') {
140
+ const appSaved = appRead();
141
+ if (appSaved !== null) appApply(appSaved);
142
+ }
143
+ /* The unfolded tree is the onlooker's memory rather than the reader's choice: it comes back even when someone
144
+ * else's link is open (otherwise a link sent over would unfold the tree again on every visit). */
145
+ appFoldRead();
146
+ appFirst();
147
+ appBooted = true;
148
+ appTransient = false;
81
149
  }
82
- /* The folded tree is the onlooker's memory rather than the reader's choice: it comes back even when someone else's
83
- * link is open (otherwise a link sent over would unfold the tree again on every visit). */
84
- appFoldRead();
85
- appRender(true);
86
- appStartup = false;
87
- appForeign = false;
88
- appTransient = false;
150
+
151
+ /* Whoever opened the page and has to know when the first drawing is over waits for this promise the checks do
152
+ * (`tools/page-harness.js`); the page itself has no use for it. */
153
+ window.appDrawn = appBegin();
89
154
 
90
155
  /* The anchor changed on an open page: the choice in the new address is applied by the same code as at opening. The
91
156
  * page's own address raises no such event (`replaceState` does not), so there is no loop here. A refusal touches
92
157
  * neither the view — the reader keeps looking at what he looked at — nor the address: it was sent to the reader,
93
- * and until he acts it is not ours. */
158
+ * and until he acts it is not ours. The message about the link stays: this drawing is exactly what it explains. */
94
159
  window.addEventListener('hashchange', () => {
95
- const state = appLinkUse();
96
- if (state === 'refused') appForeign = true;
97
- appTransient = state === 'ours';
98
- appRender(true);
99
- appForeign = false;
160
+ /* The first drawing has not happened yet: the address the page was opened with is the business of that drawing,
161
+ * and a change that arrives before it has drawn nothing to replace. */
162
+ if (!appBooted) return;
163
+ /* An address that came in from outside is read here the way it is read at opening, and a link is not the reader's
164
+ * choice until he changes something — the memory stays his own. The address itself is never rewritten: the page has
165
+ * no business in the tab's title bar, and what a link holds is read rather than made. */
166
+ appTransient = appLinkUse() === 'ours';
167
+ appPaint();
100
168
  appTransient = false;
101
169
  });
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
- .map((line) => line.replace(/^export\s+(function|const|let|var|class)\s/, '$1 '))
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
- export const PAGE_PARTS = ['./state.js', './dom.js', './panel.js', './table.js', './app.js'];
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', './work.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
- return pageSource('../derived.js') + '\n' + PAGE_PARTS.map((part) => pageSource(part)).join('');
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
- /* Accuracy in two words: a metric's caption speaks about the worst in its column, while a cell's tooltip speaks
71
- * about its own number. */
72
- exact: page.exact,
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
- /* What does not go into the file. First, the list of skipped commits: it changes with the report's own commit (one
95
- * with nothing to say lands in the list), and the file would stop being a **fixed point** a rebuild after its own
96
- * commit would yield different bytes and the hook would commit the report forever. The page has no use for the list
97
- * at all: it does not show it. It stays available to the reader `--data`, `--json` and `explain` answer from the
98
- * same run. */
99
- const NOT_IN_FILE = ['skipped'];
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 out = Object.assign({}, data);
103
- NOT_IN_FILE.forEach((key) => delete out[key]);
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,15 +252,21 @@ 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'
258
+ /* The stripe of the drawing stands first in the page and is fixed to the window's top edge rather than laid out
259
+ * with the rest: the page's shape is a grid of five rows (`src/page/app.css`), and a stripe that appeared and
260
+ * disappeared inside it would move the numbers under the reader's eyes every time a switch is drawn. It holds an
261
+ * empty child rather than a share: the length of an indeterminate stripe is the styling's business. */
262
+ + '<div id="bar" class="bar" role="progressbar" aria-label="' + esc(loc.page.working) + '" hidden><i></i></div>\n'
117
263
  + '<div id="panel" class="panel"></div>\n'
118
264
  + '<p id="notice" class="notice" hidden></p>\n'
119
265
  + '<div id="shell" class="shell"><table id="grid"></table></div>\n'
120
266
  + '<p id="state" class="state" hidden></p>\n'
121
267
  + '<p id="note" class="note"></p>\n'
122
- + '<script type="application/json" id="data">' + jsonInHtml(pagePayload(data)) + '</script>\n'
268
+ + '<script type="application/octet-stream" id="data" data-pack="base64+gzip">'
269
+ + pagePacked(jsonInHtml(pagePayload(data))) + '</script>\n'
123
270
  + '<script type="application/json" id="ui">' + jsonInHtml(uiText(loc.page, loc)) + '</script>\n'
124
271
  + '<script>\n' + pageScript() + '</script>\n</body>\n</html>\n';
125
272
  }