@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/default-config.js
CHANGED
|
@@ -89,39 +89,6 @@ 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
|
-
},
|
|
125
92
|
projectName: "miyagi",
|
|
126
93
|
ui: {
|
|
127
94
|
mode: "light",
|
package/lib/init/args.js
CHANGED
|
@@ -169,43 +169,30 @@ export default function createCli(handlers, argv = process.argv) {
|
|
|
169
169
|
commandHandler(handlers.doctor),
|
|
170
170
|
)
|
|
171
171
|
.command(
|
|
172
|
-
"
|
|
173
|
-
"
|
|
172
|
+
"perf",
|
|
173
|
+
"Reports component and page bundle sizes against miyagi.performance.json",
|
|
174
174
|
(builder) =>
|
|
175
175
|
builder
|
|
176
176
|
.option("compression", {
|
|
177
|
-
description: "
|
|
177
|
+
description: "Compression to compare against the budget",
|
|
178
178
|
type: "string",
|
|
179
179
|
choices: ["raw", "gzip", "brotli"],
|
|
180
180
|
})
|
|
181
|
+
.option("warn-ratio", {
|
|
182
|
+
description: "Override warnRatio (must be in (0, 1))",
|
|
183
|
+
type: "number",
|
|
184
|
+
})
|
|
181
185
|
.option("fail", {
|
|
182
|
-
description: "Exit
|
|
186
|
+
description: "Exit non-zero if any component or page is exceed",
|
|
183
187
|
type: "boolean",
|
|
184
188
|
default: false,
|
|
185
189
|
})
|
|
186
190
|
.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",
|
|
191
|
+
description: "Emit the full result as JSON on stdout",
|
|
205
192
|
type: "boolean",
|
|
206
193
|
default: false,
|
|
207
194
|
}),
|
|
208
|
-
commandHandler(handlers.
|
|
195
|
+
commandHandler(handlers.perf),
|
|
209
196
|
)
|
|
210
197
|
.help()
|
|
211
198
|
.version(pkgJson.version)
|
package/lib/init/router.js
CHANGED
|
@@ -8,7 +8,8 @@ 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 {
|
|
11
|
+
import { attachPerformanceRoutes } from "../performance/routes.js";
|
|
12
|
+
import { renderPageHtml } from "../performance/render-page.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* @param {object} component
|
|
@@ -64,6 +65,13 @@ function checkIfRequestedVariationIsValid(component, variation) {
|
|
|
64
65
|
* @returns {void}
|
|
65
66
|
*/
|
|
66
67
|
export default function Router() {
|
|
68
|
+
if (!global.config.isBuild) {
|
|
69
|
+
attachPerformanceRoutes(global.app, {
|
|
70
|
+
cwd: process.cwd(),
|
|
71
|
+
render: renderPageHtml,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
67
75
|
global.app.get("/design-tokens/colors", async (req, res) => {
|
|
68
76
|
return render.renderMainDesignTokens({
|
|
69
77
|
res,
|
|
@@ -103,32 +111,6 @@ export default function Router() {
|
|
|
103
111
|
});
|
|
104
112
|
});
|
|
105
113
|
|
|
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
|
-
|
|
132
114
|
global.app.get("/show", async (req, res) => {
|
|
133
115
|
const { file, variation } = req.query;
|
|
134
116
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Classify a measured byte size against an optional budget. Shared by the
|
|
5
|
+
* component and page measurement modules so the priority order (missing >
|
|
6
|
+
* unbudgeted > exceed > warn > ok) stays consistent everywhere.
|
|
7
|
+
* @param {{
|
|
8
|
+
* bytes: number,
|
|
9
|
+
* budgetBytes: number|null,
|
|
10
|
+
* warnRatio: number,
|
|
11
|
+
* missing?: boolean,
|
|
12
|
+
* }} input
|
|
13
|
+
* @returns {"ok"|"warn"|"exceed"|"unbudgeted"|"missing"}
|
|
14
|
+
*/
|
|
15
|
+
export function classify({ bytes, budgetBytes, warnRatio, missing = false }) {
|
|
16
|
+
if (missing) {
|
|
17
|
+
return "missing";
|
|
18
|
+
}
|
|
19
|
+
if (budgetBytes == null) {
|
|
20
|
+
return "unbudgeted";
|
|
21
|
+
}
|
|
22
|
+
if (bytes > budgetBytes) {
|
|
23
|
+
return "exceed";
|
|
24
|
+
}
|
|
25
|
+
// Guard against budgetBytes === 0: with bytes also 0, the warn check
|
|
26
|
+
// (bytes >= budgetBytes * warnRatio → 0 >= 0) would otherwise flip to
|
|
27
|
+
// "warn", which isn't a useful answer when nothing is shipped against
|
|
28
|
+
// a zero budget.
|
|
29
|
+
if (bytes > 0 && bytes >= budgetBytes * warnRatio) {
|
|
30
|
+
return "warn";
|
|
31
|
+
}
|
|
32
|
+
return "ok";
|
|
33
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import dependencyTree from "dependency-tree";
|
|
6
|
+
import { measure } from "./measure.js";
|
|
7
|
+
import parseSize from "./parse-size.js";
|
|
8
|
+
import { classify } from "./classify.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} AssetMeasurement
|
|
12
|
+
* @property {string} path - absolute path to the asset
|
|
13
|
+
* @property {number} bytes - measured bytes for the chosen compression
|
|
14
|
+
* @property {number|null} budget - budget in bytes, or null when unset
|
|
15
|
+
* @property {"ok"|"warn"|"exceed"|"unbudgeted"|"missing"} status - classification
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} ComponentMeasurement
|
|
20
|
+
* @property {string} componentPath - library-relative path to the component folder
|
|
21
|
+
* @property {AssetMeasurement} css - the CSS asset measurement
|
|
22
|
+
* @property {AssetMeasurement} js - the JS asset measurement
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve all files reachable from `entryPath` via static `import` (JS/TS) or
|
|
27
|
+
* `@import` (CSS) statements. Returns just the entry path when the file is
|
|
28
|
+
* missing, has no imports, or the walker fails for any reason. The walker
|
|
29
|
+
* sees what's reachable in source — it cannot tree-shake, dedup runtime-only
|
|
30
|
+
* branches, or follow dynamic imports, so the resulting number is an
|
|
31
|
+
* upper-bound proxy for what a bundler would emit.
|
|
32
|
+
* @param {string} entryPath - absolute path to the entry file
|
|
33
|
+
* @returns {string[]} absolute paths of every file in the import graph
|
|
34
|
+
*/
|
|
35
|
+
function resolveImportGraph(entryPath) {
|
|
36
|
+
if (!existsSync(entryPath)) {
|
|
37
|
+
return [entryPath];
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const list = dependencyTree.toList({
|
|
41
|
+
filename: entryPath,
|
|
42
|
+
directory: path.dirname(entryPath),
|
|
43
|
+
filter: (filePath) => !filePath.includes("node_modules"),
|
|
44
|
+
});
|
|
45
|
+
return list && list.length > 0 ? list : [entryPath];
|
|
46
|
+
} catch {
|
|
47
|
+
return [entryPath];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Measure the CSS and JS bundle sizes for a single component and classify
|
|
53
|
+
* each against the (optional) budget set in miyagi.performance.json. Walks
|
|
54
|
+
* static imports from the entry file so a 2 kB component that imports a
|
|
55
|
+
* 50 kB util shows the real reachable size, not just the entry file.
|
|
56
|
+
* @param {{
|
|
57
|
+
* cwd: string,
|
|
58
|
+
* componentPath: string,
|
|
59
|
+
* entry: { css?: { budget?: string }, js?: { budget?: string } },
|
|
60
|
+
* compression: "raw"|"gzip"|"brotli",
|
|
61
|
+
* warnRatio: number,
|
|
62
|
+
* }} options
|
|
63
|
+
* @returns {ComponentMeasurement}
|
|
64
|
+
*/
|
|
65
|
+
export function measureComponent({
|
|
66
|
+
cwd,
|
|
67
|
+
componentPath,
|
|
68
|
+
entry,
|
|
69
|
+
compression,
|
|
70
|
+
warnRatio,
|
|
71
|
+
}) {
|
|
72
|
+
const folder = path.join(cwd, componentPath);
|
|
73
|
+
const componentName = path.basename(componentPath);
|
|
74
|
+
const cssPath = path.join(folder, `${componentName}.css`);
|
|
75
|
+
const jsPath = path.join(folder, `${componentName}.js`);
|
|
76
|
+
|
|
77
|
+
const cssFiles = resolveImportGraph(cssPath);
|
|
78
|
+
const jsFiles = resolveImportGraph(jsPath);
|
|
79
|
+
|
|
80
|
+
const measurement = measure([
|
|
81
|
+
{ category: "css", files: cssFiles },
|
|
82
|
+
{ category: "js", files: jsFiles },
|
|
83
|
+
]);
|
|
84
|
+
const cssEntry = measurement.categories.css.files[0];
|
|
85
|
+
const jsEntry = measurement.categories.js.files[0];
|
|
86
|
+
const cssTotals = measurement.categories.css.totals;
|
|
87
|
+
const jsTotals = measurement.categories.js.totals;
|
|
88
|
+
|
|
89
|
+
const cssBudget = parseSize(entry?.css?.budget ?? null);
|
|
90
|
+
const jsBudget = parseSize(entry?.js?.budget ?? null);
|
|
91
|
+
|
|
92
|
+
const cssMissing = Boolean(cssEntry.missing);
|
|
93
|
+
const jsMissing = Boolean(jsEntry.missing);
|
|
94
|
+
const cssBytes = cssMissing ? 0 : cssTotals[compression];
|
|
95
|
+
const jsBytes = jsMissing ? 0 : jsTotals[compression];
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
componentPath,
|
|
99
|
+
css: {
|
|
100
|
+
path: cssPath,
|
|
101
|
+
bytes: cssBytes,
|
|
102
|
+
budget: cssBudget,
|
|
103
|
+
status: classify({
|
|
104
|
+
bytes: cssBytes,
|
|
105
|
+
budgetBytes: cssBudget,
|
|
106
|
+
warnRatio,
|
|
107
|
+
missing: cssMissing,
|
|
108
|
+
}),
|
|
109
|
+
},
|
|
110
|
+
js: {
|
|
111
|
+
path: jsPath,
|
|
112
|
+
bytes: jsBytes,
|
|
113
|
+
budget: jsBudget,
|
|
114
|
+
status: classify({
|
|
115
|
+
bytes: jsBytes,
|
|
116
|
+
budgetBytes: jsBudget,
|
|
117
|
+
warnRatio,
|
|
118
|
+
missing: jsMissing,
|
|
119
|
+
}),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import Ajv from "ajv";
|
|
6
|
+
import schema from "./schema.json" with { type: "json" };
|
|
7
|
+
|
|
8
|
+
export const CONFIG_FILE_NAME = "miyagi.performance.json";
|
|
9
|
+
|
|
10
|
+
let validator = null;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @returns {Function} compiled AJV validator (cached across calls)
|
|
14
|
+
*/
|
|
15
|
+
function getValidator() {
|
|
16
|
+
if (!validator) {
|
|
17
|
+
// useDefaults mutates the validated object so schema-declared `default`
|
|
18
|
+
// values land on the parsed config — keeps schema.json the single source
|
|
19
|
+
// of truth for default compression / warnRatio / etc.
|
|
20
|
+
const ajv = new Ajv({ allErrors: true, useDefaults: true });
|
|
21
|
+
validator = ajv.compile(schema);
|
|
22
|
+
}
|
|
23
|
+
return validator;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{cwd?: string}} [options]
|
|
28
|
+
* @returns {object|null}
|
|
29
|
+
*/
|
|
30
|
+
export function loadPerformanceConfig({ cwd } = {}) {
|
|
31
|
+
const baseDir = cwd ?? process.cwd();
|
|
32
|
+
const file = path.join(baseDir, CONFIG_FILE_NAME);
|
|
33
|
+
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = readFileSync(file, "utf-8");
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error && error.code === "ENOENT") {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(raw);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Failed to parse ${CONFIG_FILE_NAME}: invalid JSON (${error.message}).`,
|
|
50
|
+
{ cause: error },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const validate = getValidator();
|
|
55
|
+
if (!validate(parsed)) {
|
|
56
|
+
const message = (validate.errors || [])
|
|
57
|
+
.map((err) => `${err.instancePath || "/"} ${err.message}`)
|
|
58
|
+
.join("; ");
|
|
59
|
+
throw new Error(`Invalid ${CONFIG_FILE_NAME}: ${message}.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// AJV's useDefaults has already populated compression / warnRatio /
|
|
63
|
+
// components / pages from the schema's default values.
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import zlib from "node:zlib";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} html - the rendered HTML string
|
|
7
|
+
* @param {"raw"|"gzip"|"brotli"} compression - which size to report
|
|
8
|
+
* @returns {number} byte length under the chosen compression
|
|
9
|
+
*/
|
|
10
|
+
export function measureHtmlBytes(html, compression) {
|
|
11
|
+
const buffer = Buffer.from(html);
|
|
12
|
+
if (compression === "raw") {
|
|
13
|
+
return buffer.byteLength;
|
|
14
|
+
}
|
|
15
|
+
if (compression === "gzip") {
|
|
16
|
+
return zlib.gzipSync(buffer).byteLength;
|
|
17
|
+
}
|
|
18
|
+
if (compression === "brotli") {
|
|
19
|
+
return zlib.brotliCompressSync(buffer, {
|
|
20
|
+
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 6 },
|
|
21
|
+
}).byteLength;
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`Unknown compression "${compression}".`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Render a page variation through the supplied render function and report
|
|
28
|
+
* its compressed byte size. The render function is injected so this module
|
|
29
|
+
* stays pure for tests; in production the caller wires it to the existing
|
|
30
|
+
* Miyagi render pipeline.
|
|
31
|
+
* @param {{
|
|
32
|
+
* templatePath: string,
|
|
33
|
+
* variation: string,
|
|
34
|
+
* render: (templatePath: string, variation: string) => Promise<string>,
|
|
35
|
+
* compression: "raw"|"gzip"|"brotli",
|
|
36
|
+
* }} options
|
|
37
|
+
* @returns {Promise<{ html: string, bytes: number }>}
|
|
38
|
+
*/
|
|
39
|
+
export async function measureHtml({
|
|
40
|
+
templatePath,
|
|
41
|
+
variation,
|
|
42
|
+
render,
|
|
43
|
+
compression,
|
|
44
|
+
}) {
|
|
45
|
+
let html;
|
|
46
|
+
try {
|
|
47
|
+
html = await render(templatePath, variation);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Failed to render ${templatePath} (${variation}): ${error.message}`,
|
|
51
|
+
{ cause: error },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return { html, bytes: measureHtmlBytes(html, compression) };
|
|
55
|
+
}
|