@schalkneethling/miyagi-core 4.8.0 → 4.9.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/api/index.js +0 -35
- package/dist/css/iframe.css +1 -1
- package/dist/css/main.css +1 -1
- package/frontend/assets/css/iframe/perf.css +35 -0
- package/frontend/assets/css/iframe.css +1 -1
- package/frontend/assets/css/main/perf.css +63 -0
- package/frontend/assets/css/main.css +1 -0
- package/frontend/views/iframe_component.twig.miyagi +17 -0
- package/frontend/views/main.twig.miyagi +12 -0
- package/lib/build/index.js +0 -73
- package/lib/cli/index.js +2 -2
- package/lib/cli/perf.js +129 -0
- package/lib/cli/run.js +4 -4
- package/lib/default-config.js +0 -33
- package/lib/init/args.js +10 -23
- package/lib/init/router.js +9 -27
- package/lib/performance/classify.js +33 -0
- package/lib/performance/component.js +122 -0
- package/lib/performance/config.js +65 -0
- package/lib/performance/html-size.js +55 -0
- package/lib/performance/index.js +130 -374
- package/lib/performance/page.js +105 -0
- package/lib/performance/render-page.js +34 -0
- package/lib/performance/routes.js +72 -0
- package/lib/performance/schema.json +79 -0
- package/lib/performance/view-data.js +86 -0
- package/lib/render/index.js +0 -4
- package/lib/render/views/iframe/component.js +14 -0
- package/lib/render/views/main/component.js +23 -1
- package/lib/state/menu/index.js +1 -35
- package/package.json +2 -1
- package/frontend/assets/css/iframe/performance.css +0 -64
- package/frontend/views/performance.twig.miyagi +0 -72
- package/lib/cli/budget.js +0 -157
- package/lib/performance/report.js +0 -102
- package/lib/render/views/iframe/performance.js +0 -74
- package/lib/render/views/main/performance.js +0 -51
package/lib/performance/index.js
CHANGED
|
@@ -1,400 +1,156 @@
|
|
|
1
|
-
|
|
2
|
-
* Performance-budget orchestrator.
|
|
3
|
-
* Turns the Miyagi config + filesystem state into a set of measurements and
|
|
4
|
-
* evaluations against the configured budgets. The output shape is shared by the
|
|
5
|
-
* CLI, the markdown report, and the dev-UI panel so each consumer renders the
|
|
6
|
-
* same data differently rather than re-deriving it.
|
|
7
|
-
* @module performance
|
|
8
|
-
*/
|
|
1
|
+
// @ts-check
|
|
9
2
|
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
3
|
+
import { loadPerformanceConfig } from "./config.js";
|
|
4
|
+
import { measureComponent } from "./component.js";
|
|
5
|
+
import { measureHtml } from "./html-size.js";
|
|
6
|
+
import { computePageTotals } from "./page.js";
|
|
14
7
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
* @property {"ok"|"warn"|"exceed"|"unbudgeted"} status - classification against budget
|
|
23
|
-
* @property {number|null} ratio - actual / budget when budget is set
|
|
24
|
-
*/
|
|
8
|
+
const STATUS_RANK = {
|
|
9
|
+
exceed: 4,
|
|
10
|
+
missing: 3,
|
|
11
|
+
warn: 2,
|
|
12
|
+
ok: 1,
|
|
13
|
+
unbudgeted: 0,
|
|
14
|
+
};
|
|
25
15
|
|
|
26
16
|
/**
|
|
27
|
-
* @
|
|
28
|
-
* @
|
|
29
|
-
* @property {EvaluationRow[]} evaluations - budget comparison rows
|
|
30
|
-
* @property {"raw"|"gzip"|"brotli"} compression - which metric evaluations use
|
|
31
|
-
* @property {{ok: number, warn: number, exceed: number, unbudgeted: number}} summary - counts per status
|
|
17
|
+
* @param {string[]} statuses
|
|
18
|
+
* @returns {string} the worst-ranked status, or "unbudgeted" if empty
|
|
32
19
|
*/
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
* @param {string|{src: string}} entry
|
|
39
|
-
* @param {string} root
|
|
40
|
-
* @returns {string|null} absolute path, or null if the entry points at a remote URL
|
|
41
|
-
*/
|
|
42
|
-
function resolveAssetPath(entry, root) {
|
|
43
|
-
const rel = typeof entry === "string" ? entry : entry?.src;
|
|
44
|
-
|
|
45
|
-
if (!rel || URL.canParse(rel)) {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return path.resolve(root || "", rel);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Walk a directory, collecting absolute paths of all non-directory entries.
|
|
54
|
-
* Hidden files (starting with a dot) are skipped — they are almost never shipped.
|
|
55
|
-
* @param {string} dir
|
|
56
|
-
* @returns {string[]}
|
|
57
|
-
*/
|
|
58
|
-
function walkFolder(dir) {
|
|
59
|
-
if (!fs.existsSync(dir)) {
|
|
60
|
-
return [];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true, recursive: true });
|
|
64
|
-
const files = [];
|
|
65
|
-
|
|
66
|
-
for (const entry of entries) {
|
|
67
|
-
if (!entry.isFile()) {
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (entry.name.startsWith(".")) {
|
|
72
|
-
continue;
|
|
20
|
+
function worstStatus(statuses) {
|
|
21
|
+
let worst = "unbudgeted";
|
|
22
|
+
for (const s of statuses) {
|
|
23
|
+
if ((STATUS_RANK[s] ?? -1) > (STATUS_RANK[worst] ?? -1)) {
|
|
24
|
+
worst = s;
|
|
73
25
|
}
|
|
74
|
-
|
|
75
|
-
// entry.parentPath on Node 20+ gives the containing directory as an absolute
|
|
76
|
-
// path when `dir` was absolute — which it always is at our call sites.
|
|
77
|
-
files.push(path.join(entry.parentPath, entry.name));
|
|
78
26
|
}
|
|
79
|
-
|
|
80
|
-
return files;
|
|
27
|
+
return worst;
|
|
81
28
|
}
|
|
82
29
|
|
|
83
30
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* @param {object} config - global.config (or a spy equivalent in tests)
|
|
87
|
-
* @returns {{category: string, label: string, files: string[]}[]}
|
|
31
|
+
* @param {string[]} statuses
|
|
32
|
+
* @returns {{ok: number, warn: number, exceed: number, unbudgeted: number, missing: number}}
|
|
88
33
|
*/
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
...(assets.shared?.css || []),
|
|
96
|
-
]
|
|
97
|
-
.map((entry) => resolveAssetPath(entry, root))
|
|
98
|
-
.filter(Boolean);
|
|
99
|
-
|
|
100
|
-
const globalJs = [...(assets.js || []), ...(assets.shared?.js || [])]
|
|
101
|
-
.map((entry) => resolveAssetPath(entry, root))
|
|
102
|
-
.filter(Boolean);
|
|
103
|
-
|
|
104
|
-
const categories = [
|
|
105
|
-
{ category: "global-css", label: "Global CSS", files: globalCss },
|
|
106
|
-
{ category: "global-js", label: "Global JS", files: globalJs },
|
|
107
|
-
];
|
|
108
|
-
|
|
109
|
-
for (const folder of assets.folder || []) {
|
|
110
|
-
const abs = path.resolve(root, folder);
|
|
111
|
-
categories.push({
|
|
112
|
-
category: `folder:${folder}`,
|
|
113
|
-
label: `Folder — ${folder}`,
|
|
114
|
-
files: walkFolder(abs),
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return categories;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Build the list of categorised file sets for post-build HTML pages.
|
|
123
|
-
* @param {string} buildFolder
|
|
124
|
-
* @returns {{category: string, label: string, files: string[]}[]}
|
|
125
|
-
*/
|
|
126
|
-
export function collectHtmlCategory(buildFolder) {
|
|
127
|
-
const manifestPath = path.resolve(buildFolder, "output.json");
|
|
128
|
-
|
|
129
|
-
if (!fs.existsSync(manifestPath)) {
|
|
130
|
-
return [];
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** @type {Array<string|{path: string}>} */
|
|
134
|
-
let manifest;
|
|
135
|
-
try {
|
|
136
|
-
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
137
|
-
} catch {
|
|
138
|
-
return [];
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const files = manifest
|
|
142
|
-
.map((entry) => (typeof entry === "string" ? entry : entry?.path))
|
|
143
|
-
.filter((rel) => typeof rel === "string" && rel.endsWith(".html"))
|
|
144
|
-
.map((rel) => path.resolve(buildFolder, rel));
|
|
145
|
-
|
|
146
|
-
return [{ category: "html", label: "Rendered HTML pages", files }];
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* @param {number|null} actual
|
|
151
|
-
* @param {number|null} budget
|
|
152
|
-
* @returns {{status: "ok"|"warn"|"exceed"|"unbudgeted", ratio: number|null}}
|
|
153
|
-
*/
|
|
154
|
-
function classify(actual, budget) {
|
|
155
|
-
if (budget == null) {
|
|
156
|
-
return { status: "unbudgeted", ratio: null };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (budget <= 0) {
|
|
160
|
-
return { status: "unbudgeted", ratio: null };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const ratio = actual / budget;
|
|
164
|
-
|
|
165
|
-
if (actual > budget) {
|
|
166
|
-
return { status: "exceed", ratio };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (ratio >= WARN_RATIO) {
|
|
170
|
-
return { status: "warn", ratio };
|
|
34
|
+
function tally(statuses) {
|
|
35
|
+
const counts = { ok: 0, warn: 0, exceed: 0, unbudgeted: 0, missing: 0 };
|
|
36
|
+
for (const s of statuses) {
|
|
37
|
+
if (s in counts) {
|
|
38
|
+
counts[s] += 1;
|
|
39
|
+
}
|
|
171
40
|
}
|
|
172
|
-
|
|
173
|
-
return { status: "ok", ratio };
|
|
41
|
+
return counts;
|
|
174
42
|
}
|
|
175
43
|
|
|
176
44
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* @param {
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* @returns {
|
|
45
|
+
* Run the opt-in performance feature: load miyagi.performance.json, measure
|
|
46
|
+
* declared components, and (when a render function is supplied) measure each
|
|
47
|
+
* declared page variation.
|
|
48
|
+
* @param {{
|
|
49
|
+
* cwd?: string,
|
|
50
|
+
* compression?: "raw"|"gzip"|"brotli",
|
|
51
|
+
* warnRatio?: number,
|
|
52
|
+
* render?: (templatePath: string, variation: string) => Promise<string>,
|
|
53
|
+
* }} [options]
|
|
54
|
+
* @returns {Promise<object>}
|
|
187
55
|
*/
|
|
188
|
-
export function
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
]) {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const actual = categoryMeasurement.totals[compression];
|
|
208
|
-
const { status, ratio } = classify(actual, budget);
|
|
209
|
-
|
|
210
|
-
rows.push({
|
|
211
|
-
category,
|
|
212
|
-
key: "total",
|
|
213
|
-
label: labelFor(category),
|
|
214
|
-
actual,
|
|
215
|
-
budget,
|
|
216
|
-
status,
|
|
217
|
-
ratio,
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Global umbrella (CSS + JS) if set
|
|
222
|
-
if (globalBudgets.total != null) {
|
|
223
|
-
const cssTotal = measurement.categories["global-css"]?.totals[compression] ?? 0;
|
|
224
|
-
const jsTotal = measurement.categories["global-js"]?.totals[compression] ?? 0;
|
|
225
|
-
const actual = cssTotal + jsTotal;
|
|
226
|
-
const budget = parseSize(globalBudgets.total);
|
|
227
|
-
const { status, ratio } = classify(actual, budget);
|
|
228
|
-
rows.push({
|
|
229
|
-
category: "global",
|
|
230
|
-
key: "total",
|
|
231
|
-
label: "Global total (CSS + JS)",
|
|
232
|
-
actual,
|
|
233
|
-
budget,
|
|
234
|
-
status,
|
|
235
|
-
ratio,
|
|
56
|
+
export async function runPerformance(options = {}) {
|
|
57
|
+
const config = loadPerformanceConfig({ cwd: options.cwd });
|
|
58
|
+
if (!config) {
|
|
59
|
+
return { enabled: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const cwd = options.cwd ?? process.cwd();
|
|
63
|
+
const compression = options.compression ?? config.compression;
|
|
64
|
+
const warnRatio = options.warnRatio ?? config.warnRatio;
|
|
65
|
+
|
|
66
|
+
const componentMeasurements = new Map();
|
|
67
|
+
const components = [];
|
|
68
|
+
for (const [componentPath, entry] of Object.entries(config.components)) {
|
|
69
|
+
const measurement = measureComponent({
|
|
70
|
+
cwd,
|
|
71
|
+
componentPath,
|
|
72
|
+
entry,
|
|
73
|
+
compression,
|
|
74
|
+
warnRatio,
|
|
236
75
|
});
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const { status, ratio } = classify(actual, budget);
|
|
269
|
-
rows.push({
|
|
270
|
-
category: "folders",
|
|
271
|
-
key: "total",
|
|
272
|
-
label: "All folders total",
|
|
273
|
-
actual,
|
|
274
|
-
budget,
|
|
275
|
-
status,
|
|
276
|
-
ratio,
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// HTML: one row per page for perPage, one row for the total.
|
|
281
|
-
// By default only pages that exceed or warn are listed — a project with many
|
|
282
|
-
// component variants can produce hundreds of pages and the table becomes noise.
|
|
283
|
-
// Pass options.listAllPages to include every page.
|
|
284
|
-
const htmlMeasurement = measurement.categories.html;
|
|
285
|
-
if (htmlMeasurement) {
|
|
286
|
-
const perPageBudget = parseSize(htmlBudgets.perPage ?? null);
|
|
287
|
-
|
|
288
|
-
if (perPageBudget != null) {
|
|
289
|
-
let pagesChecked = 0;
|
|
290
|
-
let pagesPassed = 0;
|
|
291
|
-
|
|
292
|
-
for (const file of htmlMeasurement.files) {
|
|
293
|
-
const actual = file[compression];
|
|
294
|
-
const { status, ratio } = classify(actual, perPageBudget);
|
|
295
|
-
pagesChecked++;
|
|
296
|
-
|
|
297
|
-
if (status === "ok") {
|
|
298
|
-
pagesPassed++;
|
|
299
|
-
if (!options.listAllPages) {
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
rows.push({
|
|
305
|
-
category: "html",
|
|
306
|
-
key: file.path,
|
|
307
|
-
label: path.basename(file.path),
|
|
308
|
-
actual,
|
|
309
|
-
budget: perPageBudget,
|
|
310
|
-
status,
|
|
311
|
-
ratio,
|
|
76
|
+
components.push(measurement);
|
|
77
|
+
componentMeasurements.set(componentPath, measurement);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const pages = [];
|
|
81
|
+
if (options.render) {
|
|
82
|
+
for (const [templatePath, pageEntry] of Object.entries(config.pages)) {
|
|
83
|
+
const variations = pageEntry.variations ?? {};
|
|
84
|
+
for (const [variation, variationConfig] of Object.entries(variations)) {
|
|
85
|
+
const enrichedErrors = [];
|
|
86
|
+
// Pre-flight: components that the page references but that aren't
|
|
87
|
+
// declared at the top level can't be measured. Surface them as a
|
|
88
|
+
// separate, clearer error than the page module's "not measured".
|
|
89
|
+
const declaredComponents = variationConfig.components.filter(
|
|
90
|
+
(componentPath) => {
|
|
91
|
+
if (componentMeasurements.has(componentPath)) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
enrichedErrors.push({
|
|
95
|
+
componentPath,
|
|
96
|
+
reason: "not declared in config",
|
|
97
|
+
});
|
|
98
|
+
return false;
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const { bytes: htmlBytes } = await measureHtml({
|
|
103
|
+
templatePath,
|
|
104
|
+
variation,
|
|
105
|
+
render: options.render,
|
|
106
|
+
compression,
|
|
312
107
|
});
|
|
313
|
-
}
|
|
314
108
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
status: "ok",
|
|
324
|
-
ratio: null,
|
|
109
|
+
const totals = computePageTotals({
|
|
110
|
+
variationConfig: {
|
|
111
|
+
components: declaredComponents,
|
|
112
|
+
budget: variationConfig.budget,
|
|
113
|
+
},
|
|
114
|
+
componentMeasurements,
|
|
115
|
+
htmlBytes,
|
|
116
|
+
warnRatio,
|
|
325
117
|
});
|
|
118
|
+
// Replace the inner errors with our enriched ones (they're never both
|
|
119
|
+
// populated because we filtered the inputs).
|
|
120
|
+
totals.errors = enrichedErrors;
|
|
121
|
+
pages.push({ templatePath, variation, totals });
|
|
326
122
|
}
|
|
327
123
|
}
|
|
328
|
-
|
|
329
|
-
if (htmlBudgets.total != null) {
|
|
330
|
-
const actual = htmlMeasurement.totals[compression];
|
|
331
|
-
const budget = parseSize(htmlBudgets.total);
|
|
332
|
-
const { status, ratio } = classify(actual, budget);
|
|
333
|
-
rows.push({
|
|
334
|
-
category: "html",
|
|
335
|
-
key: "total",
|
|
336
|
-
label: "All pages total",
|
|
337
|
-
actual,
|
|
338
|
-
budget,
|
|
339
|
-
status,
|
|
340
|
-
ratio,
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
return rows;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Summarise evaluation rows into bucket counts.
|
|
350
|
-
* @param {EvaluationRow[]} rows
|
|
351
|
-
* @returns {{ok: number, warn: number, exceed: number, unbudgeted: number}}
|
|
352
|
-
*/
|
|
353
|
-
export function summarise(rows) {
|
|
354
|
-
const summary = { ok: 0, warn: 0, exceed: 0, unbudgeted: 0 };
|
|
355
|
-
|
|
356
|
-
for (const row of rows) {
|
|
357
|
-
summary[row.status] += 1;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
return summary;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Run the full pipeline: collect → measure → evaluate → summarise.
|
|
365
|
-
* @param {object} [options]
|
|
366
|
-
* @param {object} [options.config] - override global.config, for tests
|
|
367
|
-
* @param {boolean} [options.html] - include post-build HTML measurement
|
|
368
|
-
* @param {string} [options.buildFolder] - required when html is true
|
|
369
|
-
* @param {boolean} [options.listAllPages] - list every HTML page, not just those that exceed or warn
|
|
370
|
-
* @returns {PerformanceResult}
|
|
371
|
-
*/
|
|
372
|
-
export function runPerformance(options = {}) {
|
|
373
|
-
const config = options.config ?? global.config;
|
|
374
|
-
const perfConfig = config?.performance ?? {};
|
|
375
|
-
const compression = perfConfig.compression || "gzip";
|
|
376
|
-
const budgets = perfConfig.budgets || {};
|
|
377
|
-
|
|
378
|
-
const categories = collectGlobalCategories(config);
|
|
379
|
-
|
|
380
|
-
if (options.html && options.buildFolder) {
|
|
381
|
-
categories.push(...collectHtmlCategory(options.buildFolder));
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/** @type {Record<string, string>} */
|
|
385
|
-
const labelMap = {};
|
|
386
|
-
for (const category of categories) {
|
|
387
|
-
labelMap[category.category] = category.label;
|
|
388
124
|
}
|
|
389
125
|
|
|
390
|
-
const
|
|
391
|
-
|
|
126
|
+
const componentStatuses = components.map((c) =>
|
|
127
|
+
worstStatus([c.css.status, c.js.status]),
|
|
392
128
|
);
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
129
|
+
const pageStatuses = pages.map((p) => {
|
|
130
|
+
const statuses = [
|
|
131
|
+
p.totals.css.status,
|
|
132
|
+
p.totals.js.status,
|
|
133
|
+
p.totals.html.status,
|
|
134
|
+
p.totals.total.status,
|
|
135
|
+
];
|
|
136
|
+
// Pages whose declared components couldn't be measured produce
|
|
137
|
+
// numerically-incomplete totals; surface that as "missing" so the
|
|
138
|
+
// summary can't claim the page is ok/unbudgeted.
|
|
139
|
+
if (p.totals.errors && p.totals.errors.length > 0) {
|
|
140
|
+
statuses.push("missing");
|
|
141
|
+
}
|
|
142
|
+
return worstStatus(statuses);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
enabled: true,
|
|
147
|
+
compression,
|
|
148
|
+
warnRatio,
|
|
149
|
+
components,
|
|
150
|
+
pages,
|
|
151
|
+
summary: {
|
|
152
|
+
components: tally(componentStatuses),
|
|
153
|
+
pages: tally(pageStatuses),
|
|
154
|
+
},
|
|
155
|
+
};
|
|
400
156
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import parseSize from "./parse-size.js";
|
|
4
|
+
import { classify } from "./classify.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {object} PageMetric
|
|
8
|
+
* @property {number} bytes - measured size in bytes for this metric
|
|
9
|
+
* @property {number|null} budget - parsed budget bytes, or null when unset
|
|
10
|
+
* @property {"ok"|"warn"|"exceed"|"unbudgeted"} status - classification
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {object} PageError
|
|
15
|
+
* @property {string} componentPath - path of the missing/unknown component
|
|
16
|
+
* @property {string} reason - human-readable cause
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} PageTotals
|
|
21
|
+
* @property {PageMetric} css - summed CSS bytes across declared components
|
|
22
|
+
* @property {PageMetric} js - summed JS bytes across declared components
|
|
23
|
+
* @property {PageMetric} html - measured HTML bytes for this variation
|
|
24
|
+
* @property {PageMetric} total - sum of css + js + html
|
|
25
|
+
* @property {PageError[]} errors - per-component issues that didn't abort the run
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sum CSS / JS / HTML byte sizes for a single page variation and classify
|
|
30
|
+
* each metric (and the grand total) against the optional budget keys
|
|
31
|
+
* declared in miyagi.performance.json.
|
|
32
|
+
* @param {{
|
|
33
|
+
* variationConfig: {
|
|
34
|
+
* components: string[],
|
|
35
|
+
* budget?: { css?: string, js?: string, html?: string, total?: string },
|
|
36
|
+
* },
|
|
37
|
+
* componentMeasurements: Map<string, {
|
|
38
|
+
* css: { bytes: number },
|
|
39
|
+
* js: { bytes: number },
|
|
40
|
+
* }>,
|
|
41
|
+
* htmlBytes: number,
|
|
42
|
+
* warnRatio: number,
|
|
43
|
+
* }} options
|
|
44
|
+
* @returns {PageTotals}
|
|
45
|
+
*/
|
|
46
|
+
export function computePageTotals({
|
|
47
|
+
variationConfig,
|
|
48
|
+
componentMeasurements,
|
|
49
|
+
htmlBytes,
|
|
50
|
+
warnRatio,
|
|
51
|
+
}) {
|
|
52
|
+
const errors = [];
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
let cssBytes = 0;
|
|
55
|
+
let jsBytes = 0;
|
|
56
|
+
|
|
57
|
+
for (const componentPath of variationConfig.components) {
|
|
58
|
+
if (seen.has(componentPath)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
seen.add(componentPath);
|
|
62
|
+
const measurement = componentMeasurements.get(componentPath);
|
|
63
|
+
if (!measurement) {
|
|
64
|
+
errors.push({ componentPath, reason: "not measured" });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
cssBytes += measurement.css.bytes;
|
|
68
|
+
jsBytes += measurement.js.bytes;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const totalBytes = cssBytes + jsBytes + htmlBytes;
|
|
72
|
+
const budget = variationConfig.budget ?? {};
|
|
73
|
+
const cssBudget = parseSize(budget.css ?? null);
|
|
74
|
+
const jsBudget = parseSize(budget.js ?? null);
|
|
75
|
+
const htmlBudget = parseSize(budget.html ?? null);
|
|
76
|
+
const totalBudget = parseSize(budget.total ?? null);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
css: {
|
|
80
|
+
bytes: cssBytes,
|
|
81
|
+
budget: cssBudget,
|
|
82
|
+
status: classify({ bytes: cssBytes, budgetBytes: cssBudget, warnRatio }),
|
|
83
|
+
},
|
|
84
|
+
js: {
|
|
85
|
+
bytes: jsBytes,
|
|
86
|
+
budget: jsBudget,
|
|
87
|
+
status: classify({ bytes: jsBytes, budgetBytes: jsBudget, warnRatio }),
|
|
88
|
+
},
|
|
89
|
+
html: {
|
|
90
|
+
bytes: htmlBytes,
|
|
91
|
+
budget: htmlBudget,
|
|
92
|
+
status: classify({ bytes: htmlBytes, budgetBytes: htmlBudget, warnRatio }),
|
|
93
|
+
},
|
|
94
|
+
total: {
|
|
95
|
+
bytes: totalBytes,
|
|
96
|
+
budget: totalBudget,
|
|
97
|
+
status: classify({
|
|
98
|
+
bytes: totalBytes,
|
|
99
|
+
budgetBytes: totalBudget,
|
|
100
|
+
warnRatio,
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
errors,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { getVariationData } from "../mocks/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Render a configured page (a component-shaped entry under templates/...)
|
|
7
|
+
* to its raw HTML for performance measurement. Looks up the entry in
|
|
8
|
+
* global.state.routes by its shortPath, resolves the variation's mock data,
|
|
9
|
+
* and runs the template engine. Returns the rendered HTML string.
|
|
10
|
+
* @param {string} templatePath
|
|
11
|
+
* @param {string} variation
|
|
12
|
+
* @returns {Promise<string>}
|
|
13
|
+
*/
|
|
14
|
+
export async function renderPageHtml(templatePath, variation) {
|
|
15
|
+
const route = global.state?.routes?.find(
|
|
16
|
+
(r) => r.paths?.dir?.short === templatePath,
|
|
17
|
+
);
|
|
18
|
+
if (!route || !route.paths?.tpl?.full) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Performance: no template found for "${templatePath}". ` +
|
|
21
|
+
`Make sure the path matches a component's library-relative folder.`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const data = (await getVariationData(route, variation)) ?? {};
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
global.app.render(route.paths.tpl.full, data, (error, html) => {
|
|
27
|
+
if (error) {
|
|
28
|
+
reject(error);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
resolve(html);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|