@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.
Files changed (37) 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/lib/build/index.js +0 -73
  11. package/lib/cli/index.js +2 -2
  12. package/lib/cli/perf.js +129 -0
  13. package/lib/cli/run.js +4 -4
  14. package/lib/default-config.js +0 -33
  15. package/lib/init/args.js +10 -23
  16. package/lib/init/router.js +9 -27
  17. package/lib/performance/classify.js +33 -0
  18. package/lib/performance/component.js +122 -0
  19. package/lib/performance/config.js +65 -0
  20. package/lib/performance/html-size.js +55 -0
  21. package/lib/performance/index.js +130 -374
  22. package/lib/performance/page.js +105 -0
  23. package/lib/performance/render-page.js +34 -0
  24. package/lib/performance/routes.js +72 -0
  25. package/lib/performance/schema.json +79 -0
  26. package/lib/performance/view-data.js +86 -0
  27. package/lib/render/index.js +0 -4
  28. package/lib/render/views/iframe/component.js +14 -0
  29. package/lib/render/views/main/component.js +23 -1
  30. package/lib/state/menu/index.js +1 -35
  31. package/package.json +2 -1
  32. package/frontend/assets/css/iframe/performance.css +0 -64
  33. package/frontend/views/performance.twig.miyagi +0 -72
  34. package/lib/cli/budget.js +0 -157
  35. package/lib/performance/report.js +0 -102
  36. package/lib/render/views/iframe/performance.js +0 -74
  37. package/lib/render/views/main/performance.js +0 -51
@@ -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 fs from "node:fs";
11
- import path from "node:path";
12
- import { measure } from "./measure.js";
13
- import parseSize from "./parse-size.js";
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
- * @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
- */
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
- * @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
17
+ * @param {string[]} statuses
18
+ * @returns {string} the worst-ranked status, or "unbudgeted" if empty
32
19
  */
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;
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
- * 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[]}[]}
31
+ * @param {string[]} statuses
32
+ * @returns {{ok: number, warn: number, exceed: number, unbudgeted: number, missing: number}}
88
33
  */
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 };
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
- * 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[]}
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 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,
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
- // 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,
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
- // 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,
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 measurement = measure(
391
- categories.map(({ category, files }) => ({ category, files })),
126
+ const componentStatuses = components.map((c) =>
127
+ worstStatus([c.css.status, c.js.status]),
392
128
  );
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 };
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
+ }