@schalkneethling/miyagi-core 4.6.2 → 4.8.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 +35 -0
- package/dist/css/iframe.css +1 -1
- package/frontend/assets/css/iframe/performance.css +64 -0
- package/frontend/assets/css/iframe.css +1 -0
- package/frontend/views/component_variation.twig.miyagi +2 -2
- package/frontend/views/iframe_component.twig.miyagi +9 -0
- package/frontend/views/performance.twig.miyagi +72 -0
- package/lib/build/index.js +74 -0
- package/lib/cli/budget.js +157 -0
- package/lib/cli/index.js +2 -0
- package/lib/cli/run.js +11 -0
- package/lib/data-json/index.js +69 -0
- package/lib/default-config.js +33 -0
- package/lib/helpers.js +10 -2
- package/lib/init/args.js +39 -0
- package/lib/init/router.js +28 -0
- package/lib/init/watcher.js +17 -0
- package/lib/performance/index.js +400 -0
- package/lib/performance/measure.js +124 -0
- package/lib/performance/parse-size.js +86 -0
- package/lib/performance/report.js +102 -0
- package/lib/render/index.js +4 -0
- package/lib/render/views/iframe/component.js +25 -1
- package/lib/render/views/iframe/performance.js +74 -0
- package/lib/render/views/iframe/variation.standalone.js +8 -1
- package/lib/render/views/main/performance.js +51 -0
- package/lib/state/components.js +4 -0
- package/lib/state/file-contents.js +1 -0
- package/lib/state/menu/index.js +45 -4
- package/package.json +1 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { getVariationData, getComponentData } from "../mocks/index.js";
|
|
2
|
+
import log from "../logger.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolves the data.json content for a component.
|
|
6
|
+
*
|
|
7
|
+
* If no data.json exists, returns null values (no JSON exposed in DOM).
|
|
8
|
+
* If data.json has useMocks: true, uses the mocks pipeline as the data source.
|
|
9
|
+
* Otherwise, uses the data.json content directly (stripping the useMocks key).
|
|
10
|
+
* @param {object} component - the component route object
|
|
11
|
+
* @param {object} options
|
|
12
|
+
* @param {string} [options.variation] - the variation name (used when useMocks is true)
|
|
13
|
+
* @returns {Promise<{ json: string|null, id: string }>}
|
|
14
|
+
*/
|
|
15
|
+
export async function resolveDataJson(component, { variation } = {}) {
|
|
16
|
+
const dataJsonPath = component.paths.data.full;
|
|
17
|
+
const dataJsonContent = global.state.fileContents[dataJsonPath];
|
|
18
|
+
|
|
19
|
+
if (!dataJsonContent) {
|
|
20
|
+
return { json: null, id: "miyagi-mock-data" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (dataJsonContent.useMocks === true) {
|
|
24
|
+
return resolveFromMocks(component, variation);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return resolveFromDataJson(dataJsonContent);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Uses the mocks pipeline to produce the JSON content.
|
|
32
|
+
* @param {object} component
|
|
33
|
+
* @param {string} [variation]
|
|
34
|
+
* @returns {Promise<{ json: string|null, id: string }>}
|
|
35
|
+
*/
|
|
36
|
+
async function resolveFromMocks(component, variation) {
|
|
37
|
+
try {
|
|
38
|
+
let data;
|
|
39
|
+
|
|
40
|
+
if (variation) {
|
|
41
|
+
data = await getVariationData(component, variation);
|
|
42
|
+
} else {
|
|
43
|
+
const allData = await getComponentData(component);
|
|
44
|
+
data = allData?.[0] ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const resolved = data?.resolved ?? {};
|
|
48
|
+
return { json: JSON.stringify(resolved), id: "miyagi-mock-data" };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
log(
|
|
51
|
+
"error",
|
|
52
|
+
`Error resolving mock data for data.json in ${component.paths.dir.short}`,
|
|
53
|
+
err,
|
|
54
|
+
);
|
|
55
|
+
return { json: null, id: "miyagi-mock-data" };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Uses the data.json content directly, stripping internal properties.
|
|
61
|
+
* @param {object} dataJsonContent - parsed data.json object
|
|
62
|
+
* @returns {{ json: string, id: string }}
|
|
63
|
+
*/
|
|
64
|
+
function resolveFromDataJson(dataJsonContent) {
|
|
65
|
+
const clone = structuredClone(dataJsonContent);
|
|
66
|
+
delete clone.useMocks;
|
|
67
|
+
|
|
68
|
+
return { json: JSON.stringify(clone), id: "miyagi-mock-data" };
|
|
69
|
+
}
|
package/lib/default-config.js
CHANGED
|
@@ -89,6 +89,39 @@ export default {
|
|
|
89
89
|
},
|
|
90
90
|
},
|
|
91
91
|
namespaces: {},
|
|
92
|
+
// Performance budget feature — tracks asset byte size against configured
|
|
93
|
+
// limits and surfaces the state via `miyagi budget`, the build-time report,
|
|
94
|
+
// and the dev-server UI. Defaults mirror the "Slow 4G / Moto G4" tier from
|
|
95
|
+
// web.dev's "Your First Performance Budget" (see docs).
|
|
96
|
+
performance: {
|
|
97
|
+
enabled: true,
|
|
98
|
+
compression: "gzip",
|
|
99
|
+
report: {
|
|
100
|
+
failOnExceed: false,
|
|
101
|
+
output: "performance-report.md",
|
|
102
|
+
},
|
|
103
|
+
budgets: {
|
|
104
|
+
global: {
|
|
105
|
+
css: "35 kB",
|
|
106
|
+
js: "200 kB",
|
|
107
|
+
total: null,
|
|
108
|
+
},
|
|
109
|
+
html: {
|
|
110
|
+
perPage: "30 kB",
|
|
111
|
+
total: null,
|
|
112
|
+
},
|
|
113
|
+
folders: {
|
|
114
|
+
fonts: { total: "30 kB" },
|
|
115
|
+
images: { total: "50 kB" },
|
|
116
|
+
total: null,
|
|
117
|
+
},
|
|
118
|
+
perComponent: {
|
|
119
|
+
css: null,
|
|
120
|
+
js: null,
|
|
121
|
+
total: null,
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
92
125
|
projectName: "miyagi",
|
|
93
126
|
ui: {
|
|
94
127
|
mode: "light",
|
package/lib/helpers.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import v8 from "v8";
|
|
2
1
|
import path from "path";
|
|
3
2
|
|
|
4
3
|
/**
|
|
@@ -69,7 +68,7 @@ export const getResolvedFileName = function (nameInConfig, fileName) {
|
|
|
69
68
|
* @returns {object} clone of rhe given object
|
|
70
69
|
*/
|
|
71
70
|
export const cloneDeep = function (obj) {
|
|
72
|
-
return
|
|
71
|
+
return structuredClone(obj);
|
|
73
72
|
};
|
|
74
73
|
|
|
75
74
|
/**
|
|
@@ -133,6 +132,15 @@ export const fileIsSchemaFile = function (filePath) {
|
|
|
133
132
|
);
|
|
134
133
|
};
|
|
135
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Accepts a file path and checks if it is a data.json file
|
|
137
|
+
* @param {string} filePath - path to any type of file
|
|
138
|
+
* @returns {boolean} is true if the given file is a data.json file
|
|
139
|
+
*/
|
|
140
|
+
export const fileIsDataJsonFile = function (filePath) {
|
|
141
|
+
return path.basename(filePath) === "data.json";
|
|
142
|
+
};
|
|
143
|
+
|
|
136
144
|
/**
|
|
137
145
|
* Accepts a file path and checks if it is component js or css file
|
|
138
146
|
* @param {string} filePath - path to any type of file
|
package/lib/init/args.js
CHANGED
|
@@ -168,6 +168,45 @@ export default function createCli(handlers, argv = process.argv) {
|
|
|
168
168
|
() => {},
|
|
169
169
|
commandHandler(handlers.doctor),
|
|
170
170
|
)
|
|
171
|
+
.command(
|
|
172
|
+
"budget",
|
|
173
|
+
"Checks asset byte sizes against your performance budget",
|
|
174
|
+
(builder) =>
|
|
175
|
+
builder
|
|
176
|
+
.option("compression", {
|
|
177
|
+
description: "Which compression to compare against the budget",
|
|
178
|
+
type: "string",
|
|
179
|
+
choices: ["raw", "gzip", "brotli"],
|
|
180
|
+
})
|
|
181
|
+
.option("fail", {
|
|
182
|
+
description: "Exit with a non-zero code if any budget is exceeded",
|
|
183
|
+
type: "boolean",
|
|
184
|
+
default: false,
|
|
185
|
+
})
|
|
186
|
+
.option("json", {
|
|
187
|
+
description:
|
|
188
|
+
"Emit the full evaluation as JSON on stdout (for CI / automation)",
|
|
189
|
+
type: "boolean",
|
|
190
|
+
default: false,
|
|
191
|
+
})
|
|
192
|
+
.option("output", {
|
|
193
|
+
alias: "o",
|
|
194
|
+
description: "Also write a markdown report to this path",
|
|
195
|
+
type: "string",
|
|
196
|
+
})
|
|
197
|
+
.option("build-folder", {
|
|
198
|
+
description:
|
|
199
|
+
"Include post-build HTML pages from this folder (reads output.json)",
|
|
200
|
+
type: "string",
|
|
201
|
+
})
|
|
202
|
+
.option("list-all-pages", {
|
|
203
|
+
description:
|
|
204
|
+
"List every HTML page in the evaluation, not just those that exceed or warn",
|
|
205
|
+
type: "boolean",
|
|
206
|
+
default: false,
|
|
207
|
+
}),
|
|
208
|
+
commandHandler(handlers.budget),
|
|
209
|
+
)
|
|
171
210
|
.help()
|
|
172
211
|
.version(pkgJson.version)
|
|
173
212
|
.alias("help", "h")
|
package/lib/init/router.js
CHANGED
|
@@ -8,6 +8,7 @@ import config from "../default-config.js";
|
|
|
8
8
|
import render from "../render/index.js";
|
|
9
9
|
import { getVariationData } from "../mocks/index.js";
|
|
10
10
|
import log from "../logger.js";
|
|
11
|
+
import { runPerformance } from "../performance/index.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* @param {object} component
|
|
@@ -102,6 +103,32 @@ export default function Router() {
|
|
|
102
103
|
});
|
|
103
104
|
});
|
|
104
105
|
|
|
106
|
+
// Performance budget — dev-mode only.
|
|
107
|
+
if (!global.config.isBuild && global.config.performance?.enabled) {
|
|
108
|
+
global.app.get("/performance", async (req, res) => {
|
|
109
|
+
return render.renderMainPerformance({
|
|
110
|
+
res,
|
|
111
|
+
cookies: req.cookies,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
global.app.get("/iframe/performance", async (req, res) => {
|
|
116
|
+
return render.iframe.performance({
|
|
117
|
+
res,
|
|
118
|
+
cookies: req.cookies,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
global.app.get("/api/performance", (req, res) => {
|
|
123
|
+
try {
|
|
124
|
+
const result = runPerformance({ config: global.config });
|
|
125
|
+
res.json(result);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
res.status(500).json({ error: error.message });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
105
132
|
global.app.get("/show", async (req, res) => {
|
|
106
133
|
const { file, variation } = req.query;
|
|
107
134
|
|
|
@@ -279,6 +306,7 @@ export default function Router() {
|
|
|
279
306
|
componentDeclaredAssets: data?.$assets || null,
|
|
280
307
|
cookies: req.cookies,
|
|
281
308
|
overrides: overrides ?? null,
|
|
309
|
+
variation,
|
|
282
310
|
});
|
|
283
311
|
}
|
|
284
312
|
|
package/lib/init/watcher.js
CHANGED
|
@@ -298,6 +298,7 @@ async function updateFileContents(events) {
|
|
|
298
298
|
fs.lstatSync(fullPath).isFile() &&
|
|
299
299
|
(helpers.fileIsTemplateFile(relativePath) ||
|
|
300
300
|
helpers.fileIsDataFile(relativePath) ||
|
|
301
|
+
helpers.fileIsDataJsonFile(relativePath) ||
|
|
301
302
|
helpers.fileIsDocumentationFile(relativePath) ||
|
|
302
303
|
helpers.fileIsSchemaFile(relativePath))
|
|
303
304
|
) {
|
|
@@ -357,6 +358,9 @@ async function handleFileChange(events) {
|
|
|
357
358
|
const schemaEvents = events.filter(({ relativePath }) =>
|
|
358
359
|
helpers.fileIsSchemaFile(relativePath),
|
|
359
360
|
);
|
|
361
|
+
const dataJsonEvents = events.filter(({ relativePath }) =>
|
|
362
|
+
helpers.fileIsDataJsonFile(relativePath),
|
|
363
|
+
);
|
|
360
364
|
const componentAssetEvents = events.filter(({ relativePath }) =>
|
|
361
365
|
helpers.fileIsAssetFile(relativePath),
|
|
362
366
|
);
|
|
@@ -532,6 +536,19 @@ async function handleFileChange(events) {
|
|
|
532
536
|
return;
|
|
533
537
|
}
|
|
534
538
|
|
|
539
|
+
if (dataJsonEvents.length > 0) {
|
|
540
|
+
await setState({
|
|
541
|
+
fileContents: await updateFileContents(dataJsonEvents),
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
sendReload(watchRules.data, {
|
|
545
|
+
reason: "data-json",
|
|
546
|
+
paths: dataJsonEvents.map(({ relativePath }) => relativePath),
|
|
547
|
+
});
|
|
548
|
+
log("success", `${t("updatingDone")}\n`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
535
552
|
if (componentAssetEvents.length > 0) {
|
|
536
553
|
sendReload(watchRules.componentAsset, {
|
|
537
554
|
reason: "component-asset",
|
|
@@ -0,0 +1,400 @@
|
|
|
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
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { measure } from "./measure.js";
|
|
13
|
+
import parseSize from "./parse-size.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} EvaluationRow
|
|
17
|
+
* @property {string} category - "global-css" | "global-js" | "folder:<name>" | "html"
|
|
18
|
+
* @property {string} key - stable identifier inside the category ("total", "perPage", or a file path)
|
|
19
|
+
* @property {string} label - human-readable label
|
|
20
|
+
* @property {number} actual - measured bytes (at the configured compression)
|
|
21
|
+
* @property {number|null} budget - configured budget in bytes, or null if unset
|
|
22
|
+
* @property {"ok"|"warn"|"exceed"|"unbudgeted"} status - classification against budget
|
|
23
|
+
* @property {number|null} ratio - actual / budget when budget is set
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} PerformanceResult
|
|
28
|
+
* @property {import("./measure.js").Measurement} measurement - raw per-file and per-category bytes
|
|
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
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const WARN_RATIO = 0.8;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Expand a css-string or js-object asset entry to a filesystem path.
|
|
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;
|
|
73
|
+
}
|
|
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
|
+
}
|
|
79
|
+
|
|
80
|
+
return files;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the list of categorised file sets for the "always-on" (dev + CLI) scope:
|
|
85
|
+
* global CSS, global JS, and each configured asset folder.
|
|
86
|
+
* @param {object} config - global.config (or a spy equivalent in tests)
|
|
87
|
+
* @returns {{category: string, label: string, files: string[]}[]}
|
|
88
|
+
*/
|
|
89
|
+
export function collectGlobalCategories(config) {
|
|
90
|
+
const assets = config?.assets ?? {};
|
|
91
|
+
const root = assets.root || "";
|
|
92
|
+
|
|
93
|
+
const globalCss = [
|
|
94
|
+
...(assets.css || []),
|
|
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 };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { status: "ok", ratio };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Produce evaluation rows given a measurement and a budgets object.
|
|
178
|
+
* The shape of `budgets` is the `performance.budgets` config key; see
|
|
179
|
+
* lib/default-config.js for the canonical definition.
|
|
180
|
+
* @param {import("./measure.js").Measurement} measurement
|
|
181
|
+
* @param {object} budgets - performance.budgets config
|
|
182
|
+
* @param {"raw"|"gzip"|"brotli"} compression
|
|
183
|
+
* @param {{label: Record<string,string>}} meta - map category key -> human label
|
|
184
|
+
* @param {object} options
|
|
185
|
+
* @param {boolean} [options.listAllPages] - include every HTML page, not just those that exceed or warn
|
|
186
|
+
* @returns {EvaluationRow[]}
|
|
187
|
+
*/
|
|
188
|
+
export function evaluate(measurement, budgets = {}, compression = "gzip", meta = { label: {} }, options = {}) {
|
|
189
|
+
const rows = [];
|
|
190
|
+
const labelFor = (category) => meta.label[category] || category;
|
|
191
|
+
|
|
192
|
+
const globalBudgets = budgets.global || {};
|
|
193
|
+
const htmlBudgets = budgets.html || {};
|
|
194
|
+
const folderBudgets = budgets.folders || {};
|
|
195
|
+
|
|
196
|
+
// Global CSS / JS
|
|
197
|
+
for (const [category, budgetKey] of [
|
|
198
|
+
["global-css", "css"],
|
|
199
|
+
["global-js", "js"],
|
|
200
|
+
]) {
|
|
201
|
+
const categoryMeasurement = measurement.categories[category];
|
|
202
|
+
if (!categoryMeasurement) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const budget = parseSize(globalBudgets[budgetKey] ?? null);
|
|
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,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Folders
|
|
240
|
+
for (const [category, categoryMeasurement] of Object.entries(measurement.categories)) {
|
|
241
|
+
if (!category.startsWith("folder:")) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const name = category.slice("folder:".length);
|
|
245
|
+
const folderBudget = folderBudgets[name];
|
|
246
|
+
const budget = parseSize(folderBudget?.total ?? null);
|
|
247
|
+
const actual = categoryMeasurement.totals[compression];
|
|
248
|
+
const { status, ratio } = classify(actual, budget);
|
|
249
|
+
rows.push({
|
|
250
|
+
category,
|
|
251
|
+
key: "total",
|
|
252
|
+
label: labelFor(category),
|
|
253
|
+
actual,
|
|
254
|
+
budget,
|
|
255
|
+
status,
|
|
256
|
+
ratio,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (folderBudgets.total != null) {
|
|
261
|
+
let actual = 0;
|
|
262
|
+
for (const [category, categoryMeasurement] of Object.entries(measurement.categories)) {
|
|
263
|
+
if (category.startsWith("folder:")) {
|
|
264
|
+
actual += categoryMeasurement.totals[compression];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const budget = parseSize(folderBudgets.total);
|
|
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,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// When filtering, add a summary row so the reader knows pages were checked
|
|
316
|
+
if (!options.listAllPages && pagesPassed > 0) {
|
|
317
|
+
rows.push({
|
|
318
|
+
category: "html",
|
|
319
|
+
key: "pages-ok",
|
|
320
|
+
label: `${pagesPassed} of ${pagesChecked} pages within budget`,
|
|
321
|
+
actual: 0,
|
|
322
|
+
budget: null,
|
|
323
|
+
status: "ok",
|
|
324
|
+
ratio: null,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
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
|
+
}
|
|
389
|
+
|
|
390
|
+
const measurement = measure(
|
|
391
|
+
categories.map(({ category, files }) => ({ category, files })),
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const evaluations = evaluate(measurement, budgets, compression, {
|
|
395
|
+
label: labelMap,
|
|
396
|
+
}, { listAllPages: options.listAllPages });
|
|
397
|
+
const summary = summarise(evaluations);
|
|
398
|
+
|
|
399
|
+
return { measurement, evaluations, compression, summary };
|
|
400
|
+
}
|