webperf-snippets 1.5.0 â 1.6.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/README.md +14 -6
- package/package.json +1 -1
- package/snippets/CoreWebVitals/INP.js +6 -4
- package/snippets/Interaction/Input-Latency-Breakdown.js +3 -2
- package/snippets/Interaction/Interactions.js +8 -1
- package/snippets/Loading/Cache-Strategy-Analysis.js +25 -23
- package/snippets/Loading/Critical-CSS-Detection.js +2 -1
- package/snippets/SCHEMA.md +7 -4
- package/snippets/_view/fields.js +37 -0
- package/snippets/_view/format.js +39 -0
- package/snippets/_view/index.js +304 -0
- package/snippets/_view/locales/en.js +39 -0
- package/snippets/_view/package.json +1 -0
- package/src/bin.js +40 -37
- package/src/budgets.js +73 -0
- package/src/mcp/server.js +1 -1
- package/src/reporters/budgets-view.js +38 -0
- package/src/reporters/human.js +42 -20
- package/src/reporters/markdown.js +53 -1
- package/src/reporters/summary-view.js +12 -0
- package/src/runner.js +3 -3
- package/src/view.js +11 -0
- package/src/workflows/interaction.js +1 -0
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ npx webperf-snippets https://example.com --json
|
|
|
73
73
|
The JSON output records how the page was measured in `conditions`, so a later run can tell whether it measured the same way: the viewport, the workflow (or the path of the snippet with `--snippet`), the wait and the version of the CLI.
|
|
74
74
|
|
|
75
75
|
```json
|
|
76
|
-
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.
|
|
76
|
+
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.6.0" }
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
Single snippet:
|
|
@@ -158,7 +158,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
158
158
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
159
159
|
"navMs": 1240,
|
|
160
160
|
"results": [],
|
|
161
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.
|
|
161
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.6.0" }
|
|
162
162
|
}
|
|
163
163
|
```
|
|
164
164
|
|
|
@@ -300,6 +300,8 @@ CI gating:
|
|
|
300
300
|
npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
|
|
301
301
|
```
|
|
302
302
|
|
|
303
|
+
Each budget compares the value measured, or the median with `--runs`, and fails when the value is above it. `--budget-lcp` and `--budget-cls` need the `core-web-vitals` workflow, `--budget-fcp` the `loading` workflow, `--budget-ttfb` the `loading` or `audit` workflow, and `--budget-inp` the `interaction` workflow with a click or a key press; `--snippet` with the snippet of the metric works too. A budget of a metric that the run does not measure exits with code `2` before the page loads, and one whose metric was not measured, such as INP without interactions, exits with code `1`: a budget never passes unchecked. The report lists every budget with its value and verdict, `passed`, `exceeded` with by how much, or `not measured` with why: a Budgets section in the human and markdown reports, and `budgets` in the JSON output and in the body of `--report-to`. A matrix lists the failed budgets with their combination under its table. Each failed budget is also written to stderr, with `--json` too.
|
|
304
|
+
|
|
303
305
|
### Options
|
|
304
306
|
|
|
305
307
|
| Option | Description |
|
|
@@ -325,8 +327,11 @@ npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
|
|
|
325
327
|
| `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
|
|
326
328
|
| `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
|
|
327
329
|
| `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
|
|
328
|
-
| `--budget-lcp <ms>` | Exit `1` if LCP exceeds this value. |
|
|
329
|
-
| `--budget-
|
|
330
|
+
| `--budget-lcp <ms>` | Exit `1` if LCP exceeds this value or was not measured. |
|
|
331
|
+
| `--budget-fcp <ms>` | Exit `1` if FCP exceeds this value or was not measured. |
|
|
332
|
+
| `--budget-ttfb <ms>` | Exit `1` if TTFB exceeds this value or was not measured. |
|
|
333
|
+
| `--budget-cls <score>` | Exit `1` if CLS exceeds this value or was not measured. |
|
|
334
|
+
| `--budget-inp <ms>` | Exit `1` if INP exceeds this value or was not measured. |
|
|
330
335
|
| `--verbose` | Show all items, including passing checks. |
|
|
331
336
|
| `--headed` | Show the browser window (debug). |
|
|
332
337
|
| `-h, --help` | Show help. |
|
|
@@ -453,8 +458,11 @@ jobs:
|
|
|
453
458
|
| ---------------- | ------------------- | -------------------------------------------------------------------------------------------- |
|
|
454
459
|
| `url` | required | URL to measure. |
|
|
455
460
|
| `workflow` | `core-web-vitals` | Workflow to run: `core-web-vitals`, `loading`, `audit` or `interaction`. |
|
|
456
|
-
| `budget-lcp` | `
|
|
457
|
-
| `budget-
|
|
461
|
+
| `budget-lcp` | `auto` | LCP budget in ms. `auto` is `2500` with `core-web-vitals` and no budget with the other workflows. An empty value disables the check. |
|
|
462
|
+
| `budget-fcp` | | FCP budget in ms, for `loading`. |
|
|
463
|
+
| `budget-ttfb` | | TTFB budget in ms, for `loading` or `audit`. |
|
|
464
|
+
| `budget-cls` | `auto` | CLS budget. `auto` is `0.1` with `core-web-vitals` and no budget with the other workflows. An empty value disables the check. |
|
|
465
|
+
| `budget-inp` | | INP budget in ms, for `interaction` with a click or a key press. |
|
|
458
466
|
| `fail-on-budget` | `true` | Fail the step when a budget is exceeded or a snippet errors. Use `"false"` to read `exit-code` and decide yourself. |
|
|
459
467
|
| `cli-version` | `latest` | Version of the `webperf-snippets` npm package to run. |
|
|
460
468
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"mcpName": "io.github.nucliweb/webperf-snippets",
|
|
5
5
|
"description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
|
|
6
6
|
"type": "module",
|
|
@@ -349,10 +349,11 @@
|
|
|
349
349
|
};
|
|
350
350
|
}
|
|
351
351
|
if (interactions.length === 0) {
|
|
352
|
+
// Nothing failed: someone has to use the page first, so it is still tracking
|
|
352
353
|
return {
|
|
353
354
|
script: "INP",
|
|
354
|
-
status: "
|
|
355
|
-
|
|
355
|
+
status: "tracking",
|
|
356
|
+
message: "No interactions yet: click, tap or type on the page, then call getINP() again.",
|
|
356
357
|
getDataFn: "getINP",
|
|
357
358
|
details,
|
|
358
359
|
};
|
|
@@ -378,8 +379,9 @@
|
|
|
378
379
|
console.groupEnd();
|
|
379
380
|
return {
|
|
380
381
|
script: "INP",
|
|
381
|
-
status: "
|
|
382
|
-
|
|
382
|
+
status: "tracking",
|
|
383
|
+
message: "No interactions yet: click, tap or type on the page, then call getINPDetails() again.",
|
|
384
|
+
getDataFn: "getINPDetails",
|
|
383
385
|
count: 0,
|
|
384
386
|
items: [],
|
|
385
387
|
};
|
|
@@ -132,10 +132,11 @@
|
|
|
132
132
|
console.log(
|
|
133
133
|
" Interact with the page (click, type, etc.) and call this again."
|
|
134
134
|
);
|
|
135
|
+
// Nothing failed: someone has to use the page first, so it is still tracking
|
|
135
136
|
return {
|
|
136
137
|
script: "Input-Latency-Breakdown",
|
|
137
|
-
status: "
|
|
138
|
-
|
|
138
|
+
status: "tracking",
|
|
139
|
+
message: "No interactions yet: click, tap or type on the page, then call getInputLatencyBreakdown() again.",
|
|
139
140
|
getDataFn: "getInputLatencyBreakdown",
|
|
140
141
|
};
|
|
141
142
|
}
|
|
@@ -184,7 +184,14 @@
|
|
|
184
184
|
if (allInteractions.length === 0) {
|
|
185
185
|
console.log("%cđ No interactions recorded yet.", "font-weight: bold;");
|
|
186
186
|
console.log(" Interact with the page (click, type, etc.) and call this again.");
|
|
187
|
-
|
|
187
|
+
// Nothing failed: someone has to use the page first, so it is still tracking
|
|
188
|
+
return {
|
|
189
|
+
script: "Interactions",
|
|
190
|
+
status: "tracking",
|
|
191
|
+
message: "No interactions yet: click, tap or type on the page, then call getInteractionSummary() again.",
|
|
192
|
+
getDataFn: "getInteractionSummary",
|
|
193
|
+
count: 0,
|
|
194
|
+
};
|
|
188
195
|
}
|
|
189
196
|
|
|
190
197
|
console.group("%cđ Interaction Summary", "font-weight: bold; font-size: 14px;");
|
|
@@ -800,56 +800,58 @@
|
|
|
800
800
|
|
|
801
801
|
console.groupEnd(); // Additional Insights
|
|
802
802
|
|
|
803
|
-
// Recommendations
|
|
803
|
+
// Recommendations: the console marks their priority; the result orders them by it, as plain text
|
|
804
|
+
const PRIORITY = ["đ´", "đĄ", "âšī¸"];
|
|
804
805
|
const recommendations = [];
|
|
806
|
+
const recommend = (icon, text) => recommendations.push({ icon, text });
|
|
805
807
|
|
|
806
808
|
if (noCacheEntries.filter((e) => isStaticAsset(e.type)).length > 0) {
|
|
807
|
-
|
|
808
|
-
"
|
|
809
|
+
recommend("đ´",
|
|
810
|
+
"Add Cache-Control headers to static assets (JS, CSS, fonts, images). Start with max-age=86400 and move to immutable for versioned files."
|
|
809
811
|
);
|
|
810
812
|
}
|
|
811
813
|
if (shortCacheEntries.filter((e) => e.hasHash).length > 0) {
|
|
812
|
-
|
|
813
|
-
"
|
|
814
|
+
recommend("đĄ",
|
|
815
|
+
"Versioned files (with content hash) should use long cache or Cache-Control: immutable; they can be cached indefinitely since the URL changes on update."
|
|
814
816
|
);
|
|
815
817
|
}
|
|
816
818
|
if (allAntiPatterns.some((p) => p.id === "expires-without-cc")) {
|
|
817
|
-
|
|
818
|
-
"
|
|
819
|
+
recommend("đĄ",
|
|
820
|
+
"Replace Expires headers with Cache-Control: max-age. Expires is an outdated mechanism and less reliable."
|
|
819
821
|
);
|
|
820
822
|
}
|
|
821
823
|
if (cacheEfficiencyPercent !== null && cacheEfficiencyPercent < 50) {
|
|
822
|
-
|
|
823
|
-
|
|
824
|
+
recommend("đ´",
|
|
825
|
+
`Cache efficiency is low (${cacheEfficiencyPercent}%, measured on the ${analyzableEntries.length} resources whose cache headers could be read). Review caching strategy for those static assets.`
|
|
824
826
|
);
|
|
825
827
|
}
|
|
826
828
|
if (cdnHitRate !== null && cdnHitRate < 70) {
|
|
827
|
-
|
|
828
|
-
|
|
829
|
+
recommend("đĄ",
|
|
830
|
+
`CDN hit rate is ${cdnHitRate}%. Check CDN cache rules and ensure static assets have appropriate TTLs.`
|
|
829
831
|
);
|
|
830
832
|
}
|
|
831
833
|
if (protocolDistribution["http/1.1"] > 0) {
|
|
832
|
-
|
|
833
|
-
|
|
834
|
+
recommend("đĄ",
|
|
835
|
+
`${protocolDistribution["http/1.1"]} resources still use HTTP/1.1. Consider upgrading to HTTP/2 for better multiplexing.`
|
|
834
836
|
);
|
|
835
837
|
}
|
|
836
838
|
if (uncompressedResources.length > 0) {
|
|
837
|
-
|
|
838
|
-
|
|
839
|
+
recommend("đĄ",
|
|
840
|
+
`${uncompressedResources.length} text resources appear uncompressed. Enable Brotli or gzip compression on your server.`
|
|
839
841
|
);
|
|
840
842
|
}
|
|
841
843
|
if (costlyDuplicates.length > 0) {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
+
recommend("đĄ",
|
|
845
|
+
`${costlyDuplicates.length} resources are loaded multiple times with uncached network requests. Check for duplicate script, stylesheet, or fetch includes.`
|
|
844
846
|
);
|
|
845
847
|
} else if (cachedDuplicates.length > 0) {
|
|
846
|
-
|
|
847
|
-
|
|
848
|
+
recommend("âšī¸",
|
|
849
|
+
`${cachedDuplicates.length} resources are requested multiple times but served from cache; low performance impact, but worth reviewing for correctness.`
|
|
848
850
|
);
|
|
849
851
|
}
|
|
850
852
|
if (corsRestricted > 0 && headersAnalyzed === 0) {
|
|
851
|
-
|
|
852
|
-
|
|
853
|
+
recommend("âšī¸",
|
|
854
|
+
`All resources are cross-origin CORS-restricted, so header analysis was limited. Add Timing-Allow-Origin for more insights.`
|
|
853
855
|
);
|
|
854
856
|
}
|
|
855
857
|
|
|
@@ -857,7 +859,7 @@
|
|
|
857
859
|
console.log("");
|
|
858
860
|
console.group("%cđ Recommendations", "font-weight: bold;");
|
|
859
861
|
for (const rec of recommendations) {
|
|
860
|
-
console.log(` ${rec}`);
|
|
862
|
+
console.log(` ${rec.icon} ${rec.text}`);
|
|
861
863
|
}
|
|
862
864
|
console.groupEnd();
|
|
863
865
|
}
|
|
@@ -935,6 +937,6 @@
|
|
|
935
937
|
})),
|
|
936
938
|
...(coverageIssue ? [coverageIssue] : []),
|
|
937
939
|
],
|
|
938
|
-
recommendations,
|
|
940
|
+
recommendations: [...recommendations].sort((a, b) => PRIORITY.indexOf(a.icon) - PRIORITY.indexOf(b.icon)).map((r) => r.text),
|
|
939
941
|
};
|
|
940
942
|
})();
|
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
const criticalBudget = 14 * 1024; // 14 KB (first TCP round-trip)
|
|
25
25
|
|
|
26
26
|
// --- External stylesheets ---
|
|
27
|
+
// A link without href or disabled loads nothing and blocks nothing, so it is not a stylesheet of the page
|
|
27
28
|
const linkStylesheets = Array.from(
|
|
28
29
|
document.querySelectorAll('link[rel="stylesheet"]')
|
|
29
|
-
);
|
|
30
|
+
).filter((link) => link.href && !link.disabled);
|
|
30
31
|
|
|
31
32
|
const perfEntries = performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e));
|
|
32
33
|
|
package/snippets/SCHEMA.md
CHANGED
|
@@ -59,6 +59,9 @@ Every script must return an object matching this shape:
|
|
|
59
59
|
target?: string; // The URL, selector or name the issue is about, when there is one issue per thing
|
|
60
60
|
}>;
|
|
61
61
|
|
|
62
|
+
// Next steps in plain text, most important first, without emoji (Cache-Strategy-Analysis)
|
|
63
|
+
recommendations?: string[];
|
|
64
|
+
|
|
62
65
|
// Tracking scripts (status: "tracking")
|
|
63
66
|
message?: string; // Human-readable status message
|
|
64
67
|
getDataFn?: string; // window function to call for data: evaluate_script(`${getDataFn}()`). May be a dotted path such as "loafHelpers.getData"
|
|
@@ -170,7 +173,7 @@ return {
|
|
|
170
173
|
**The window function must also return a structured object** matching the same schema, including when nothing has been recorded yet:
|
|
171
174
|
|
|
172
175
|
- `status: "ok"` with `count: 0` when zero is a valid result (no long tasks, no long animation frames).
|
|
173
|
-
- `status: "
|
|
176
|
+
- `status: "tracking"` with a `message` and its `getDataFn` when there is nothing to report yet and someone has to act first (no interactions recorded): nothing failed, the snippet is still recording. `status: "error"` is for a snippet that failed.
|
|
174
177
|
|
|
175
178
|
**Scripts using this pattern:** INP, Interactions, Input-Latency-Breakdown, Layout-Shift-Loading-and-Interaction, Scroll-Performance, Long-Animation-Frames (ongoing tracking), LongTask (ongoing tracking), Long-Animation-Frames-Script-Attribution.
|
|
176
179
|
|
|
@@ -262,11 +265,11 @@ Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` afte
|
|
|
262
265
|
}
|
|
263
266
|
```
|
|
264
267
|
|
|
265
|
-
If no interactions yet, `getINP()` returns `status: "
|
|
268
|
+
If no interactions yet, `getINP()` returns `status: "tracking"` with a `message` and `getDataFn: "getINP"`: interact with the page and call it again.
|
|
266
269
|
|
|
267
270
|
The phases describe the whole interaction, not one of its events. A tap fires `pointerdown`, `pointerup` and `click`, and a key press `keydown` and `keyup`, with one `interactionId`. INP, Interactions and Input-Latency-Breakdown group the entries by `interactionId`, also when they arrive in separate observer callbacks, and measure the interaction over its entries painted in the frame of the longest one: `inputDelay` runs from the first `startTime` to the first `processingStart`, `processingTime` to the last `processingEnd`, and `presentationDelay` from there to the paint (`startTime + duration`, never below zero). The interaction is reported under the event whose handler ran longest, or under the `click` or `keydown` when no handler took a millisecond more than theirs; `worstEvent`, the Interactions `type` and the Input-Latency-Breakdown event type follow that rule.
|
|
268
271
|
|
|
269
|
-
`getINPDetails()` returns the 15 slowest interactions, slowest first, with the total in `count`. `target` is a CSS selector built like `details.element` of LCP, or `null` when the browser reports no target. Use it when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions. With no interactions yet it returns `status: "
|
|
272
|
+
`getINPDetails()` returns the 15 slowest interactions, slowest first, with the total in `count`. `target` is a CSS selector built like `details.element` of LCP, or `null` when the browser reports no target. Use it when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions. With no interactions yet it returns `status: "tracking"`, a `message`, `count: 0` and no items.
|
|
270
273
|
|
|
271
274
|
```json
|
|
272
275
|
{
|
|
@@ -1398,7 +1401,7 @@ Each issue names the preloaded script in `target`.
|
|
|
1398
1401
|
|
|
1399
1402
|
### Cache-Strategy-Analysis
|
|
1400
1403
|
|
|
1401
|
-
Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes.
|
|
1404
|
+
Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. `recommendations` lists the next steps in plain text, the most important first; the console marks their priority with đ´, đĄ and âšī¸.
|
|
1402
1405
|
|
|
1403
1406
|
| Code | Severity | When |
|
|
1404
1407
|
|------|----------|------|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// How each field of a result is shown, the same in every language: its format, its place among the
|
|
2
|
+
// others (a lower order comes first; a field without one keeps its place after them) and whether it is
|
|
3
|
+
// hidden. The entries of a snippet win over the general ones under "*". A field without an entry takes
|
|
4
|
+
// its format from the suffix of its key (format.js). The words are in locales/.
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
'*': {
|
|
8
|
+
// A CSS selector of an element of the page, which a consumer can make inspectable
|
|
9
|
+
selector: { format: 'selector' },
|
|
10
|
+
element: { format: 'selector' },
|
|
11
|
+
lcpElement: { format: 'selector' },
|
|
12
|
+
finalElement: { format: 'selector' },
|
|
13
|
+
// The name of a phase, read as its label
|
|
14
|
+
slowestPhase: { format: 'key' },
|
|
15
|
+
// The issues of a row: the issues section of the result lists them already
|
|
16
|
+
issues: { hidden: true },
|
|
17
|
+
},
|
|
18
|
+
'Find-Above-The-Fold-Lazy-Loaded-Images': {
|
|
19
|
+
// Its top and left in px: the distance from the edge of the viewport says what matters
|
|
20
|
+
position: { hidden: true },
|
|
21
|
+
},
|
|
22
|
+
'Input-Latency-Breakdown': {
|
|
23
|
+
// The same as its rows, one per event type
|
|
24
|
+
eventTypes: { hidden: true },
|
|
25
|
+
},
|
|
26
|
+
INP: {
|
|
27
|
+
phases: { format: 'phases' },
|
|
28
|
+
},
|
|
29
|
+
'Find-render-blocking-resources': {
|
|
30
|
+
shortName: { order: 1 },
|
|
31
|
+
type: { order: 2 },
|
|
32
|
+
responseEndMs: { order: 3 },
|
|
33
|
+
durationMs: { order: 4 },
|
|
34
|
+
sizeBytes: { order: 5 },
|
|
35
|
+
url: { order: 6 },
|
|
36
|
+
},
|
|
37
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The formats of the values of a result, with the numbers written as the locale writes them. A field
|
|
2
|
+
// without a format in fields.js takes it from the suffix of its key.
|
|
3
|
+
|
|
4
|
+
const KB = 1024
|
|
5
|
+
const MB = 1024 * 1024
|
|
6
|
+
|
|
7
|
+
const number = (value, locale, digits = 0, minimum = 0) =>
|
|
8
|
+
new Intl.NumberFormat(locale, { maximumFractionDigits: digits, minimumFractionDigits: minimum }).format(value)
|
|
9
|
+
|
|
10
|
+
const FORMATS = {
|
|
11
|
+
ms: (value, locale) => `${number(Math.round(value), locale)} ms`,
|
|
12
|
+
bytes: (value, locale) =>
|
|
13
|
+
value >= MB ? `${number(value / MB, locale, 1, 1)} MB` : value >= KB ? `${number(value / KB, locale, 1, 1)} KB` : `${number(value, locale)} B`,
|
|
14
|
+
percent: (value, locale) => new Intl.NumberFormat(locale, { style: 'unit', unit: 'percent', maximumFractionDigits: 1 }).format(value),
|
|
15
|
+
count: (value, locale) => number(value, locale),
|
|
16
|
+
score: (value, locale) => number(value, locale, 3, 3),
|
|
17
|
+
bpp: (value, locale) => `${number(value, locale, 3, 3)} bpp`,
|
|
18
|
+
fps: (value, locale) => `${number(Math.round(value), locale)} fps`,
|
|
19
|
+
px: (value, locale) => `${number(Math.round(value), locale)} px`,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The suffixes that name a unit, and the format each one gives
|
|
23
|
+
export const SUFFIXES = [
|
|
24
|
+
['Ms', 'ms'],
|
|
25
|
+
['Bytes', 'bytes'],
|
|
26
|
+
['Percent', 'percent'],
|
|
27
|
+
['Count', 'count'],
|
|
28
|
+
['Px', 'px'],
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
export const formatOfKey = (key) => SUFFIXES.find(([suffix]) => key.endsWith(suffix) && key !== suffix)?.[1]
|
|
32
|
+
|
|
33
|
+
// Text as it is, a number in its format, and an object or a list as compact JSON
|
|
34
|
+
export function formatValue(value, format, locale = 'en') {
|
|
35
|
+
if (value === undefined || value === null) return ''
|
|
36
|
+
if (typeof value === 'number') return (FORMATS[format] ?? ((v, l) => number(v, l, 3)))(value, locale)
|
|
37
|
+
if (typeof value === 'object') return JSON.stringify(value)
|
|
38
|
+
return String(value)
|
|
39
|
+
}
|