@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.
@@ -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
+ }
@@ -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 v8.deserialize(v8.serialize(obj));
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")
@@ -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
 
@@ -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
+ }