@schalkneethling/miyagi-core 4.8.1 → 4.9.1

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.
Files changed (38) hide show
  1. package/api/index.js +0 -35
  2. package/dist/css/iframe.css +1 -1
  3. package/dist/css/main.css +1 -1
  4. package/frontend/assets/css/iframe/perf.css +35 -0
  5. package/frontend/assets/css/iframe.css +1 -1
  6. package/frontend/assets/css/main/perf.css +63 -0
  7. package/frontend/assets/css/main.css +1 -0
  8. package/frontend/views/iframe_component.twig.miyagi +17 -0
  9. package/frontend/views/main.twig.miyagi +12 -0
  10. package/frontend/views/menu/nav.twig.miyagi +1 -1
  11. package/lib/build/index.js +0 -73
  12. package/lib/cli/index.js +2 -2
  13. package/lib/cli/perf.js +130 -0
  14. package/lib/cli/run.js +4 -4
  15. package/lib/default-config.js +0 -33
  16. package/lib/init/args.js +10 -23
  17. package/lib/init/router.js +10 -27
  18. package/lib/performance/classify.js +33 -0
  19. package/lib/performance/component.js +124 -0
  20. package/lib/performance/config.js +65 -0
  21. package/lib/performance/html-size.js +55 -0
  22. package/lib/performance/index.js +133 -374
  23. package/lib/performance/page.js +105 -0
  24. package/lib/performance/render-page.js +34 -0
  25. package/lib/performance/routes.js +74 -0
  26. package/lib/performance/schema.json +79 -0
  27. package/lib/performance/view-data.js +86 -0
  28. package/lib/render/index.js +0 -4
  29. package/lib/render/views/iframe/component.js +17 -0
  30. package/lib/render/views/main/component.js +24 -1
  31. package/lib/state/menu/index.js +1 -35
  32. package/package.json +2 -1
  33. package/frontend/assets/css/iframe/performance.css +0 -64
  34. package/frontend/views/performance.twig.miyagi +0 -72
  35. package/lib/cli/budget.js +0 -157
  36. package/lib/performance/report.js +0 -102
  37. package/lib/render/views/iframe/performance.js +0 -74
  38. package/lib/render/views/main/performance.js +0 -51
@@ -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
- "budget",
173
- "Checks asset byte sizes against your performance budget",
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: "Which compression to compare against the budget",
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 with a non-zero code if any budget is exceeded",
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.budget),
195
+ commandHandler(handlers.perf),
209
196
  )
210
197
  .help()
211
198
  .version(pkgJson.version)
@@ -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 { runPerformance } from "../performance/index.js";
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,14 @@ 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
+ componentsFolder: global.config.components.folder,
72
+ render: renderPageHtml,
73
+ });
74
+ }
75
+
67
76
  global.app.get("/design-tokens/colors", async (req, res) => {
68
77
  return render.renderMainDesignTokens({
69
78
  res,
@@ -103,32 +112,6 @@ export default function Router() {
103
112
  });
104
113
  });
105
114
 
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
115
  global.app.get("/show", async (req, res) => {
133
116
  const { file, variation } = req.query;
134
117
 
@@ -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,124 @@
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
+ * componentsFolder: string,
59
+ * componentPath: string,
60
+ * entry: { css?: { budget?: string }, js?: { budget?: string } },
61
+ * compression: "raw"|"gzip"|"brotli",
62
+ * warnRatio: number,
63
+ * }} options
64
+ * @returns {ComponentMeasurement}
65
+ */
66
+ export function measureComponent({
67
+ cwd,
68
+ componentsFolder = "",
69
+ componentPath,
70
+ entry,
71
+ compression,
72
+ warnRatio,
73
+ }) {
74
+ const folder = path.join(cwd, componentsFolder, componentPath);
75
+ const componentName = path.basename(componentPath);
76
+ const cssPath = path.join(folder, `${componentName}.css`);
77
+ const jsPath = path.join(folder, `${componentName}.js`);
78
+
79
+ const cssFiles = resolveImportGraph(cssPath);
80
+ const jsFiles = resolveImportGraph(jsPath);
81
+
82
+ const measurement = measure([
83
+ { category: "css", files: cssFiles },
84
+ { category: "js", files: jsFiles },
85
+ ]);
86
+ const cssEntry = measurement.categories.css.files[0];
87
+ const jsEntry = measurement.categories.js.files[0];
88
+ const cssTotals = measurement.categories.css.totals;
89
+ const jsTotals = measurement.categories.js.totals;
90
+
91
+ const cssBudget = parseSize(entry?.css?.budget ?? null);
92
+ const jsBudget = parseSize(entry?.js?.budget ?? null);
93
+
94
+ const cssMissing = Boolean(cssEntry.missing);
95
+ const jsMissing = Boolean(jsEntry.missing);
96
+ const cssBytes = cssMissing ? 0 : cssTotals[compression];
97
+ const jsBytes = jsMissing ? 0 : jsTotals[compression];
98
+
99
+ return {
100
+ componentPath,
101
+ css: {
102
+ path: cssPath,
103
+ bytes: cssBytes,
104
+ budget: cssBudget,
105
+ status: classify({
106
+ bytes: cssBytes,
107
+ budgetBytes: cssBudget,
108
+ warnRatio,
109
+ missing: cssMissing,
110
+ }),
111
+ },
112
+ js: {
113
+ path: jsPath,
114
+ bytes: jsBytes,
115
+ budget: jsBudget,
116
+ status: classify({
117
+ bytes: jsBytes,
118
+ budgetBytes: jsBudget,
119
+ warnRatio,
120
+ missing: jsMissing,
121
+ }),
122
+ },
123
+ };
124
+ }
@@ -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
+ }