@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
@@ -0,0 +1,74 @@
1
+ // @ts-check
2
+
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { CONFIG_FILE_NAME } from "./config.js";
6
+ import { runPerformance } from "./index.js";
7
+
8
+ /**
9
+ * Attach performance API routes to the supplied Express app. Routes are
10
+ * always registered; each handler returns 404 if miyagi.performance.json
11
+ * is absent at request time, so users can drop the file in or out without
12
+ * restarting the server. The runPerformance() file-size + html compression
13
+ * caches make repeat hits cheap.
14
+ *
15
+ * Routes:
16
+ * GET /api/performance/components
17
+ * GET /api/performance/pages
18
+ * GET /api/performance/pages/:templatePath/:variation
19
+ * @param {object} app - Express app
20
+ * @param {{
21
+ * cwd: string,
22
+ * componentsFolder?: string,
23
+ * render?: (templatePath: string, variation: string) => Promise<string>,
24
+ * }} options
25
+ * @returns {boolean} true when the config file exists at registration time
26
+ */
27
+ export function attachPerformanceRoutes(app, options) {
28
+ const handle = async (req, res, transform) => {
29
+ try {
30
+ const result = await runPerformance({
31
+ cwd: options.cwd,
32
+ componentsFolder: options.componentsFolder,
33
+ render: options.render,
34
+ });
35
+ if (!result.enabled) {
36
+ res.status(404).json({ error: "Performance feature not configured." });
37
+ return;
38
+ }
39
+ const transformed = transform(result, res);
40
+ if (!res.headersSent) {
41
+ res.json(transformed);
42
+ }
43
+ } catch (error) {
44
+ res.status(500).json({ error: error.message });
45
+ }
46
+ };
47
+
48
+ app.get("/api/performance/components", (req, res) =>
49
+ handle(req, res, (result) => result.components),
50
+ );
51
+
52
+ app.get("/api/performance/pages", (req, res) =>
53
+ handle(req, res, (result) => result.pages),
54
+ );
55
+
56
+ app.get("/api/performance/pages/:templatePath/:variation", (req, res) =>
57
+ handle(req, res, (result) => {
58
+ // Express has already percent-decoded req.params; calling
59
+ // decodeURIComponent again would over-decode and throw URIError on
60
+ // legitimate values containing "%".
61
+ const { templatePath, variation } = req.params;
62
+ const match = result.pages.find(
63
+ (p) => p.templatePath === templatePath && p.variation === variation,
64
+ );
65
+ if (!match) {
66
+ res.status(404).json({ error: "Page not found." });
67
+ return undefined;
68
+ }
69
+ return match;
70
+ }),
71
+ );
72
+
73
+ return existsSync(path.join(options.cwd, CONFIG_FILE_NAME));
74
+ }
@@ -0,0 +1,79 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "miyagi.performance.json",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "properties": {
7
+ "compression": {
8
+ "type": "string",
9
+ "enum": ["raw", "gzip", "brotli"],
10
+ "default": "gzip"
11
+ },
12
+ "warnRatio": {
13
+ "type": "number",
14
+ "exclusiveMinimum": 0,
15
+ "exclusiveMaximum": 1,
16
+ "default": 0.8
17
+ },
18
+ "components": {
19
+ "type": "object",
20
+ "minProperties": 0,
21
+ "propertyNames": { "minLength": 1 },
22
+ "default": {},
23
+ "additionalProperties": {
24
+ "type": "object",
25
+ "additionalProperties": false,
26
+ "properties": {
27
+ "css": { "$ref": "#/definitions/assetEntry" },
28
+ "js": { "$ref": "#/definitions/assetEntry" }
29
+ }
30
+ }
31
+ },
32
+ "pages": {
33
+ "default": {},
34
+ "type": "object",
35
+ "propertyNames": { "minLength": 1 },
36
+ "additionalProperties": {
37
+ "type": "object",
38
+ "additionalProperties": false,
39
+ "required": ["variations"],
40
+ "properties": {
41
+ "variations": {
42
+ "type": "object",
43
+ "propertyNames": { "minLength": 1 },
44
+ "additionalProperties": {
45
+ "type": "object",
46
+ "additionalProperties": false,
47
+ "required": ["components"],
48
+ "properties": {
49
+ "components": {
50
+ "type": "array",
51
+ "items": { "type": "string", "minLength": 1 }
52
+ },
53
+ "budget": {
54
+ "type": "object",
55
+ "additionalProperties": false,
56
+ "properties": {
57
+ "css": { "type": "string" },
58
+ "js": { "type": "string" },
59
+ "html": { "type": "string" },
60
+ "total": { "type": "string" }
61
+ }
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
69
+ },
70
+ "definitions": {
71
+ "assetEntry": {
72
+ "type": "object",
73
+ "additionalProperties": false,
74
+ "properties": {
75
+ "budget": { "type": "string" }
76
+ }
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,86 @@
1
+ // @ts-check
2
+
3
+ import { formatSize } from "./parse-size.js";
4
+
5
+ /**
6
+ * @param {object} metric - the metric object from runPerformance
7
+ * @returns {object} compact view-data shape for the Twig templates
8
+ */
9
+ function metricView(metric) {
10
+ return {
11
+ bytes: metric.bytes,
12
+ bytesLabel: formatSize(metric.bytes),
13
+ budget: metric.budget,
14
+ budgetLabel: metric.budget == null ? null : formatSize(metric.budget),
15
+ status: metric.status,
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Build the component-overview Performance section view-data for a given
21
+ * component path. Returns null when the feature is disabled or the component
22
+ * isn't listed in miyagi.performance.json.
23
+ * @param {object|null} runResult - return value of runPerformance() or null
24
+ * @param {string} componentPath
25
+ * @returns {object|null}
26
+ */
27
+ export function buildComponentPerfSection(runResult, componentPath) {
28
+ if (!runResult || !runResult.enabled) {
29
+ return null;
30
+ }
31
+ const match = runResult.components.find(
32
+ (c) => c.componentPath === componentPath,
33
+ );
34
+ if (!match) {
35
+ return null;
36
+ }
37
+ return {
38
+ componentPath,
39
+ css: { ...metricView(match.css), path: match.css.path },
40
+ js: { ...metricView(match.js), path: match.js.path },
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Build the page-banner view-data for a given template + variation. Returns
46
+ * null when the feature is disabled or the page isn't listed in
47
+ * miyagi.performance.json.
48
+ * @param {object|null} runResult - return value of runPerformance() or null
49
+ * @param {string} templatePath
50
+ * @param {string} variation
51
+ * @returns {object|null}
52
+ */
53
+ export function buildPagePerfBanner(runResult, templatePath, variation) {
54
+ if (!runResult || !runResult.enabled) {
55
+ return null;
56
+ }
57
+ const match = runResult.pages.find(
58
+ (p) => p.templatePath === templatePath && p.variation === variation,
59
+ );
60
+ if (!match) {
61
+ return null;
62
+ }
63
+ const t = match.totals;
64
+ return {
65
+ templatePath,
66
+ variation,
67
+ css: metricView(t.css),
68
+ js: metricView(t.js),
69
+ html: metricView(t.html),
70
+ total: metricView(t.total),
71
+ components: variationComponents(match.totals),
72
+ errors: t.errors,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Surfaces undeclared / missing component paths from the totals' errors
78
+ * array — used as a tooltip aid in the banner so users can see which
79
+ * components weren't measured. Components that *were* measured aren't
80
+ * listed here; the banner already shows their summed bytes.
81
+ * @param {object} totals - the page totals object from runPerformance
82
+ * @returns {string[]} component paths flagged in totals.errors
83
+ */
84
+ function variationComponents(totals) {
85
+ return (totals.errors || []).map((e) => e.componentPath);
86
+ }
@@ -15,8 +15,6 @@ import renderMainDocs from "./views/main/docs.js";
15
15
  import renderMainIndex from "./views/main/index.js";
16
16
  import iframeDesignTokens from "./views/iframe/design-tokens/index.js";
17
17
  import renderMainDesignTokens from "./views/main/design-tokens.js";
18
- import renderMainPerformance from "./views/main/performance.js";
19
- import renderIframePerformance from "./views/iframe/performance.js";
20
18
 
21
19
  export default {
22
20
  renderMainIndex,
@@ -30,13 +28,11 @@ export default {
30
28
  renderIframeDocs,
31
29
  renderIframeIndex,
32
30
  renderMainDesignTokens,
33
- renderMainPerformance,
34
31
  iframe: {
35
32
  designTokens: {
36
33
  colors: iframeDesignTokens.colors,
37
34
  sizes: iframeDesignTokens.sizes,
38
35
  typography: iframeDesignTokens.typography,
39
36
  },
40
- performance: renderIframePerformance,
41
37
  },
42
38
  };
@@ -8,6 +8,8 @@ import { getComponentData } from "../../../mocks/index.js";
8
8
  import { getUserUiConfig, getThemeMode } from "../../helpers.js";
9
9
  import resolveAssets from "../../helpers/resolve-assets.js";
10
10
  import log from "../../../logger.js";
11
+ import { runPerformance } from "../../../performance/index.js";
12
+ import { buildComponentPerfSection } from "../../../performance/view-data.js";
11
13
 
12
14
  /**
13
15
  * @param {object} object - parameter object
@@ -246,9 +248,24 @@ async function renderVariations({
246
248
  componentDeclaredAssets,
247
249
  );
248
250
 
251
+ let perfSection;
252
+ try {
253
+ const perfResult = await runPerformance({
254
+ cwd: process.cwd(),
255
+ componentsFolder: global.config.components.folder,
256
+ });
257
+ perfSection = buildComponentPerfSection(
258
+ perfResult,
259
+ component.paths.dir.short,
260
+ );
261
+ } catch {
262
+ perfSection = null;
263
+ }
264
+
249
265
  await res.render(
250
266
  "iframe_component.twig.miyagi",
251
267
  {
268
+ perfSection,
252
269
  lang: global.config.ui.lang,
253
270
  variations,
254
271
  cssFiles,
@@ -1,6 +1,9 @@
1
1
  import config from "../../../default-config.js";
2
2
  import * as helpers from "../../../helpers.js";
3
3
  import { getUserUiConfig, getThemeMode } from "../../helpers.js";
4
+ import { runPerformance } from "../../../performance/index.js";
5
+ import { buildPagePerfBanner } from "../../../performance/view-data.js";
6
+ import { renderPageHtml } from "../../../performance/render-page.js";
4
7
 
5
8
  /**
6
9
  * @param {object} object - parameter object
@@ -27,7 +30,7 @@ export default async function renderMainComponent({
27
30
  `-variation-${helpers.normalizeString(variation)}.html`,
28
31
  );
29
32
  } else {
30
- iframeSrc += `&variation=${variation}`;
33
+ iframeSrc += `&variation=${encodeURIComponent(variation)}`;
31
34
  }
32
35
  }
33
36
 
@@ -37,9 +40,29 @@ export default async function renderMainComponent({
37
40
  iframeSrc += "&embedded=true";
38
41
  }
39
42
 
43
+ let perfBanner = null;
44
+ if (!global.config.isBuild && variation) {
45
+ try {
46
+ const perfResult = await runPerformance({
47
+ cwd: process.cwd(),
48
+ componentsFolder: global.config.components.folder,
49
+ render: renderPageHtml,
50
+ });
51
+ perfBanner = buildPagePerfBanner(
52
+ perfResult,
53
+ component.paths.dir.short,
54
+ variation,
55
+ );
56
+ } catch {
57
+ // Performance is opt-in; never let measurement errors block rendering.
58
+ perfBanner = null;
59
+ }
60
+ }
61
+
40
62
  await res.render(
41
63
  "main.twig.miyagi",
42
64
  {
65
+ perfBanner,
43
66
  lang: global.config.ui.lang,
44
67
  folders: global.state.menu,
45
68
  components: global.state.components,
@@ -221,9 +221,8 @@ export const getMenu = function (sourceTree) {
221
221
 
222
222
  const docsMenu = getDocsMenu(sourceTree.docs);
223
223
  const designTokensMenu = getDesignTokensMenu();
224
- const performanceMenu = getPerformanceMenu();
225
224
 
226
- if (!docsMenu && !designTokensMenu && !performanceMenu && componentsMenu) {
225
+ if (!docsMenu && !designTokensMenu && componentsMenu) {
227
226
  return componentsMenu.children;
228
227
  }
229
228
 
@@ -237,43 +236,10 @@ export const getMenu = function (sourceTree) {
237
236
  if (docsMenu) {
238
237
  menus.push(docsMenu);
239
238
  }
240
- if (performanceMenu) {
241
- menus.push(performanceMenu);
242
- }
243
239
 
244
240
  return menus;
245
241
  };
246
242
 
247
- /**
248
- * @returns {object|null}
249
- */
250
- function getPerformanceMenu() {
251
- // Dev-mode only. In build output there is no live dev-server to compute
252
- // perf against, so the entry would dead-link.
253
- if (global.config.isBuild) {
254
- return null;
255
- }
256
- if (!global.config.performance?.enabled) {
257
- return null;
258
- }
259
-
260
- return {
261
- topLevel: true,
262
- name: "Performance",
263
- id: "performance",
264
- type: "directory",
265
- shortPath: "performance",
266
- children: [
267
- {
268
- section: "performance",
269
- type: "file",
270
- name: "budget",
271
- url: "/iframe/performance",
272
- },
273
- ],
274
- };
275
- }
276
-
277
243
  /**
278
244
  * @returns {object}
279
245
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schalkneethling/miyagi-core",
3
- "version": "4.8.1",
3
+ "version": "4.9.1",
4
4
  "description": "miyagi is a component development tool for JavaScript template engines.",
5
5
  "main": "index.js",
6
6
  "author": "Schalk Neethling <schalkneethling@duck.com>, Michael Großklaus <mail@mgrossklaus.de> (https://www.mgrossklaus.de)",
@@ -41,6 +41,7 @@
41
41
  "chokidar": "^5.0.0",
42
42
  "cookie-parser": "^1.4.7",
43
43
  "deepmerge": "^4.3.1",
44
+ "dependency-tree": "^11.4.3",
44
45
  "directory-tree": "^3.5.2",
45
46
  "express": "^5.1.0",
46
47
  "html-validate": "^10.11.2",
@@ -1,64 +0,0 @@
1
- .PerformanceTable {
2
- border-collapse: collapse;
3
- margin-block: 1em;
4
- width: 100%;
5
- }
6
-
7
- .PerformanceTable :is(th, td) {
8
- padding: 0.35em 0.75em;
9
- text-align: start;
10
- vertical-align: top;
11
- }
12
-
13
- .PerformanceTable thead th {
14
- border-block-end: 0.0625rem solid currentcolor;
15
- font-weight: bold;
16
- text-transform: uppercase;
17
- font-size: 0.875em;
18
- letter-spacing: 0.0375em;
19
- }
20
-
21
- .PerformanceTable tbody tr:not(:last-child) :is(td, th) {
22
- border-block-end: 0.0625rem solid var(--color-Outline);
23
- }
24
-
25
- .PerformanceTable-totals :is(th, td) {
26
- font-weight: bold;
27
- }
28
-
29
- .PerformanceTable tr[data-missing="true"] td {
30
- color: var(--color-Iframe-text-secondary);
31
- }
32
-
33
- .PerformanceStatus {
34
- display: inline-block;
35
- padding: 0.15em 0.6em;
36
- border-radius: 0.25em;
37
- font-size: 0.875em;
38
- font-weight: bold;
39
- letter-spacing: 0.0375em;
40
- text-transform: uppercase;
41
- }
42
-
43
- .PerformanceStatus--ok {
44
- background: color-mix(in srgb, var(--color-Positive) 20%, transparent);
45
- color: var(--color-Positive);
46
- }
47
-
48
- .PerformanceStatus--warn {
49
- background: color-mix(
50
- in srgb,
51
- var(--color-Iframe-text-secondary) 25%,
52
- transparent
53
- );
54
- color: var(--color-Iframe-text);
55
- }
56
-
57
- .PerformanceStatus--exceed {
58
- background: color-mix(in srgb, var(--color-Negative) 20%, transparent);
59
- color: var(--color-Negative);
60
- }
61
-
62
- .PerformanceStatus--unbudgeted {
63
- color: var(--color-Iframe-text-secondary);
64
- }
@@ -1,72 +0,0 @@
1
- {% extends "@miyagi/layouts/iframe_default.twig.miyagi" %}
2
- {% block body %}
3
- <div class="Wrapper">
4
- <div class="Documentation">
5
- <h1>Performance budget</h1>
6
- <p>Measured at <strong>{{ compression }}</strong>. OK: {{ summary.ok }} · Warn: {{ summary.warn }} · Exceed: {{ summary.exceed }} · Unbudgeted: {{ summary.unbudgeted }}.</p>
7
-
8
- <h2>Evaluations</h2>
9
- <table class="PerformanceTable">
10
- <thead>
11
- <tr>
12
- <th scope="col">Category</th>
13
- <th scope="col">Item</th>
14
- <th scope="col">Actual</th>
15
- <th scope="col">Budget</th>
16
- <th scope="col">Status</th>
17
- </tr>
18
- </thead>
19
- <tbody>
20
- {% for row in evaluations %}
21
- <tr data-status="{{ row.status }}">
22
- <td>{{ row.category }}</td>
23
- <td>{% if row.key == "total" %}Total{% else %}{{ row.label }}{% endif %}</td>
24
- <td>{{ row.actualFormatted }}</td>
25
- <td>{{ row.budgetFormatted }}</td>
26
- <td>
27
- <span class="PerformanceStatus PerformanceStatus--{{ row.status }}">
28
- {% if row.status == "ok" %}OK{% elseif row.status == "warn" %}WARN{% elseif row.status == "exceed" %}EXCEED{% else %}—{% endif %}
29
- </span>
30
- {% if row.ratioPercent is not null %}
31
- <small>({{ row.ratioPercent }}%)</small>
32
- {% endif %}
33
- </td>
34
- </tr>
35
- {% endfor %}
36
- </tbody>
37
- </table>
38
-
39
- {% for cat in categories %}
40
- {% if cat.files|length > 0 %}
41
- <h2>{{ cat.category }}</h2>
42
- <table class="PerformanceTable">
43
- <thead>
44
- <tr>
45
- <th scope="col">File</th>
46
- <th scope="col">Raw</th>
47
- <th scope="col">Gzip</th>
48
- <th scope="col">Brotli</th>
49
- </tr>
50
- </thead>
51
- <tbody>
52
- {% for file in cat.files %}
53
- <tr{% if file.missing %} data-missing="true"{% endif %}>
54
- <td>{{ file.path }}{% if file.missing %} <em>(missing)</em>{% endif %}</td>
55
- <td>{{ file.raw }}</td>
56
- <td>{{ file.gzip }}</td>
57
- <td>{{ file.brotli }}</td>
58
- </tr>
59
- {% endfor %}
60
- <tr class="PerformanceTable-totals">
61
- <th scope="row">Total</th>
62
- <td>{{ cat.totals.raw }}</td>
63
- <td>{{ cat.totals.gzip }}</td>
64
- <td>{{ cat.totals.brotli }}</td>
65
- </tr>
66
- </tbody>
67
- </table>
68
- {% endif %}
69
- {% endfor %}
70
- </div>
71
- </div>
72
- {% endblock %}