@joycostudio/iris 0.0.0-stage → 0.0.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.
- package/LICENSE +15 -0
- package/README.md +137 -2
- package/dist/browser.cjs +466 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +69 -0
- package/dist/browser.d.ts +69 -0
- package/dist/browser.js +439 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.cjs +681 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +658 -0
- package/dist/cli.js.map +1 -0
- package/dist/core.cjs +355 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +8 -0
- package/dist/core.d.ts +8 -0
- package/dist/core.js +328 -0
- package/dist/core.js.map +1 -0
- package/dist/next.cjs +380 -0
- package/dist/next.cjs.map +1 -0
- package/dist/next.d.cts +16 -0
- package/dist/next.d.ts +16 -0
- package/dist/next.js +352 -0
- package/dist/next.js.map +1 -0
- package/dist/node.cjs +580 -0
- package/dist/node.cjs.map +1 -0
- package/dist/node.d.cts +47 -0
- package/dist/node.d.ts +47 -0
- package/dist/node.js +542 -0
- package/dist/node.js.map +1 -0
- package/dist/policy-types-LoD2Ldcl.d.cts +147 -0
- package/dist/policy-types-LoD2Ldcl.d.ts +147 -0
- package/dist/react.cjs +628 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +42 -0
- package/dist/react.d.ts +42 -0
- package/dist/react.js +611 -0
- package/dist/react.js.map +1 -0
- package/dist/types-Dv3DS4OX.d.cts +17 -0
- package/dist/types-Dv3DS4OX.d.ts +17 -0
- package/package.json +102 -4
- package/skills/iris-audit/SKILL.md +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JOYCO Studio
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,138 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Iris
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Iris turns image sources and usage context into a loading plan. It has three parts:
|
|
4
|
+
|
|
5
|
+
- **Prepare assets:** Find image files and hardcoded URLs in your code, inspect their metadata, then generate a catalog and small placeholders.
|
|
6
|
+
- **Define a policy:** Recipes describe loading stages. Rules choose a recipe using image metadata and how the image is used.
|
|
7
|
+
- **Render the plan:** The Next.js adapter applies the policy with Next Image. The core policy can also drive other renderers.
|
|
8
|
+
|
|
9
|
+
Next.js remains responsible for responsive sizes, optimization, and the final image request.
|
|
10
|
+
|
|
11
|
+
## Get started
|
|
12
|
+
|
|
13
|
+
Requires Node.js 22.18+, Next.js 16+, and Sharp for image generation.
|
|
14
|
+
|
|
15
|
+
### 1. Install and initialize
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm i @joycostudio/iris
|
|
19
|
+
npm i -D sharp
|
|
20
|
+
npx iris init
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### 2. Set up `iris.config.ts`
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createIris } from "@joycostudio/iris"
|
|
27
|
+
|
|
28
|
+
export default createIris({
|
|
29
|
+
assets: ["public/images/**/*.{png,jpg,jpeg,webp,avif,gif}"],
|
|
30
|
+
recipes: {
|
|
31
|
+
direct: { preview: "none" },
|
|
32
|
+
blur: { placeholder: "blur" },
|
|
33
|
+
staged: { placeholder: "blur", preview: { ratio: 0.5 } },
|
|
34
|
+
},
|
|
35
|
+
rules: [
|
|
36
|
+
{ when: { priority: true }, recipe: "direct" },
|
|
37
|
+
{ include: "/images/gallery/**", recipe: "staged" },
|
|
38
|
+
],
|
|
39
|
+
fallback: "blur",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
export const generate = {
|
|
43
|
+
output: "src/generated/iris",
|
|
44
|
+
scan: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}"],
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`assets` catalogs files in `public/images`, including images no page uses yet. `scan` finds image URLs already written in your JS/TS files, including JSX like `<Image src="/images/hero.png" />`. The first matching rule chooses a recipe; `fallback` covers everything else.
|
|
49
|
+
|
|
50
|
+
### 3. Generate and use it
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npx iris generate
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
import { IrisImage } from "@/generated/iris/image"
|
|
58
|
+
|
|
59
|
+
<IrisImage src="/images/hero.png" alt="Hero" fill sizes="100vw" priority />
|
|
60
|
+
<IrisImage src="/images/gallery/photo.jpg" alt="Photo" width={1200} height={800} />
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Use regular Next Image props. Run `iris generate` again when image sources or placeholder encoding settings change. An image missing from the catalog renders as ordinary Next Image.
|
|
64
|
+
|
|
65
|
+
## Recipe and rules
|
|
66
|
+
|
|
67
|
+
### Recipes
|
|
68
|
+
|
|
69
|
+
A **recipe** is a named loading behavior. It controls what appears while the final image loads; Next.js still chooses and requests the final image.
|
|
70
|
+
|
|
71
|
+
| Recipe in the example | Definition | Result |
|
|
72
|
+
| --------------------- | -------------------------------------------------- | ------------------------------------------------------- |
|
|
73
|
+
| `direct` | `{ preview: "none" }` | Final image, without an Iris preview |
|
|
74
|
+
| `blur` | `{ placeholder: "blur" }` | Tiny blurred placeholder, then final image |
|
|
75
|
+
| `staged` | `{ placeholder: "blur", preview: { ratio: 0.5 } }` | Placeholder, optional smaller preview, then final image |
|
|
76
|
+
|
|
77
|
+
Define each recipe under `recipes` and refer to it by name in a rule or `fallback`. A recipe can also be a synchronous function when its behavior depends on the image:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
recipes: {
|
|
81
|
+
adaptive: ({ asset, usage }) => ({
|
|
82
|
+
placeholder: "blur",
|
|
83
|
+
preview: asset.bytes > 500_000 && !usage.priority ? { ratio: 0.5 } : "none",
|
|
84
|
+
}),
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`placeholder` accepts `"none"`, `"blur"`, or blur settings. `preview` accepts `"none"` or settings such as `ratio`, `maxWidth`, and `quality`.
|
|
89
|
+
|
|
90
|
+
### Rules
|
|
91
|
+
|
|
92
|
+
A **rule** says _when_ to use a named recipe. When `IrisImage` renders, Iris tests `rules` using catalog metadata and image props. It checks from top to bottom and uses the first match. If none match, it uses `fallback`. The `recipe` prop on one `IrisImage` overrides this selection.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
rules: [
|
|
96
|
+
{ when: { priority: true }, recipe: "direct" },
|
|
97
|
+
{
|
|
98
|
+
include: "/images/gallery/**",
|
|
99
|
+
when: { sourceBytes: { min: 500_000 } },
|
|
100
|
+
recipe: "staged",
|
|
101
|
+
},
|
|
102
|
+
{ when: ({ asset }) => asset.alpha === true, recipe: "direct" },
|
|
103
|
+
]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Here, a priority gallery image uses `direct` because that rule comes first. A nonpriority gallery image over 500 KB uses `staged`; an image matching no rule uses `fallback`.
|
|
107
|
+
|
|
108
|
+
Each rule can have `include` and `exclude` filters for the source URL, plus a `when` condition. They must all pass for the rule to match. `when` can be an object of conditions, as above, or a synchronous function. `include` and `exclude` accept strings/globs, regular expressions, functions, and arrays. Recipe callbacks and rule predicates receive the same context:
|
|
109
|
+
|
|
110
|
+
| Field | What it contains |
|
|
111
|
+
| ---------- | ------------------------------------------------------------------------------------------- |
|
|
112
|
+
| `src` | The image URL |
|
|
113
|
+
| `asset` | Catalog metadata for the original image, such as `width`, `bytes`, `format`, and `alpha` |
|
|
114
|
+
| `variants` | Known image variants plus the original |
|
|
115
|
+
| `usage` | Render context, such as `width` in CSS pixels, `dpr`, `priority`, `tags`, and `custom` data |
|
|
116
|
+
|
|
117
|
+
Object conditions include `priority`, `sourceBytes`, `sourceWidth`, `renderedWidth`, `targetWidth`, `format`, and `alpha`. Size conditions use inclusive `{ min?, max? }` ranges. `renderedWidth` comes from `usage.width` (or a fixed `width` prop); `targetWidth` is that width multiplied by `usage.dpr` (default `1`). For a `fill` image, pass `usage={{ width: 800 }}` if a rule needs its rendered width.
|
|
118
|
+
|
|
119
|
+
## Common project needs
|
|
120
|
+
|
|
121
|
+
| Need | Use |
|
|
122
|
+
| -------------------------------------------------------- | ---------------------------------------------------- |
|
|
123
|
+
| Find hardcoded image URLs in app code | `generate.scan` |
|
|
124
|
+
| Include all images in a folder | `assets` with a `public/**` glob |
|
|
125
|
+
| Skip tests, fixtures, or specific URLs | `generate.exclude` |
|
|
126
|
+
| Add CMS or remote image URLs | `generate.sources` or an async `generate()` function |
|
|
127
|
+
| Choose a recipe by priority, image size, format, or path | `rules` with `when` or `include` |
|
|
128
|
+
| Inspect generated assets and policy results | `generate.report: true` |
|
|
129
|
+
|
|
130
|
+
`scan` recognizes literal PNG, JPEG, WebP, AVIF, and GIF URLs, including full remote URLs. It cannot evaluate variables, imported assets, or interpolated paths; add those through `assets` or `generate.sources`. Remote images must be reachable when you run `iris generate`. Scanning does not rewrite your code: use `IrisImage` where you want the policy applied.
|
|
131
|
+
|
|
132
|
+
## Reference
|
|
133
|
+
|
|
134
|
+
See the [full reference](https://github.com/joyco-studio/iris/blob/main/docs/reference.md) for [configuration options](https://github.com/joyco-studio/iris/blob/main/docs/reference.md#configuration-reference), [loading behavior](https://github.com/joyco-studio/iris/blob/main/docs/reference.md#how-it-works), [other renderers](https://github.com/joyco-studio/iris/blob/main/docs/reference.md#other-renderers), and [debugging](https://github.com/joyco-studio/iris/blob/main/docs/reference.md#debugging).
|
|
135
|
+
|
|
136
|
+
## Maintainers
|
|
137
|
+
|
|
138
|
+
See [releasing Iris to npm](https://github.com/joyco-studio/iris/blob/main/docs/releasing.md).
|
package/dist/browser.cjs
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// packages/browser/index.ts
|
|
21
|
+
var browser_exports = {};
|
|
22
|
+
__export(browser_exports, {
|
|
23
|
+
createIrisDebugger: () => createIrisDebugger,
|
|
24
|
+
diagnoseRuntime: () => diagnoseRuntime,
|
|
25
|
+
inspectImageElement: () => inspectImageElement
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(browser_exports);
|
|
28
|
+
function inspectImageElement(image, manifest = {}, lcpElements = /* @__PURE__ */ new Set(), timing = null) {
|
|
29
|
+
const rect = image.getBoundingClientRect();
|
|
30
|
+
const renderedWidth = image.clientWidth || rect.width;
|
|
31
|
+
const renderedHeight = image.clientHeight || rect.height;
|
|
32
|
+
const id = image.dataset.iris || image.currentSrc || image.src;
|
|
33
|
+
const source = manifest[image.dataset.irisSrc || id] ?? manifest[id] ?? manifest[image.currentSrc] ?? manifest[image.src] ?? null;
|
|
34
|
+
const candidates = parseSrcSet(image.srcset, renderedWidth);
|
|
35
|
+
const selectedUrl = image.currentSrc || image.src;
|
|
36
|
+
const selectedWidth = selectedCandidateWidth(selectedUrl, candidates);
|
|
37
|
+
const dpr = window.devicePixelRatio || 1;
|
|
38
|
+
const idealWidth = Math.ceil(renderedWidth * dpr);
|
|
39
|
+
const declaredSlotWidth = resolveSizes(image.sizes);
|
|
40
|
+
const resource = resourceTiming(selectedUrl);
|
|
41
|
+
const diagnostics = diagnoseRuntime({
|
|
42
|
+
source,
|
|
43
|
+
renderedWidth,
|
|
44
|
+
renderedHeight,
|
|
45
|
+
visualWidth: rect.width,
|
|
46
|
+
visualHeight: rect.height,
|
|
47
|
+
idealWidth,
|
|
48
|
+
selectedWidth,
|
|
49
|
+
availableWidths: candidates.map((candidate) => candidate.width),
|
|
50
|
+
declaredSlotWidth,
|
|
51
|
+
objectFit: getComputedStyle(image).objectFit
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
source,
|
|
56
|
+
renderedWidth,
|
|
57
|
+
renderedHeight,
|
|
58
|
+
visualWidth: rect.width,
|
|
59
|
+
visualHeight: rect.height,
|
|
60
|
+
naturalWidth: image.naturalWidth,
|
|
61
|
+
dpr,
|
|
62
|
+
idealWidth,
|
|
63
|
+
selectedWidth,
|
|
64
|
+
selectedUrl,
|
|
65
|
+
availableWidths: candidates.map((candidate) => candidate.width),
|
|
66
|
+
declaredSizes: image.sizes,
|
|
67
|
+
declaredSlotWidth,
|
|
68
|
+
loading: image.loading,
|
|
69
|
+
objectFit: getComputedStyle(image).objectFit,
|
|
70
|
+
transferBytes: resource?.transferSize ?? null,
|
|
71
|
+
duration: resource?.duration ?? null,
|
|
72
|
+
timing,
|
|
73
|
+
isLcp: lcpElements.has(image),
|
|
74
|
+
diagnostics
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function createIrisDebugger(options = {}) {
|
|
78
|
+
const reports = /* @__PURE__ */ new Map();
|
|
79
|
+
const lcpElements = /* @__PURE__ */ new Set();
|
|
80
|
+
let mutationObserver = null;
|
|
81
|
+
let resizeObserver = null;
|
|
82
|
+
let performanceObserver = null;
|
|
83
|
+
let frame = 0;
|
|
84
|
+
let tableTimer = null;
|
|
85
|
+
let lastTableSignature = "";
|
|
86
|
+
const pending = /* @__PURE__ */ new Set();
|
|
87
|
+
const timelines = /* @__PURE__ */ new WeakMap();
|
|
88
|
+
const selector = options.scope === "document" ? "img" : "img[data-iris]";
|
|
89
|
+
const measure = (image) => {
|
|
90
|
+
const report2 = inspectImageElement(
|
|
91
|
+
image,
|
|
92
|
+
options.manifest,
|
|
93
|
+
lcpElements,
|
|
94
|
+
timelines.get(image) ?? null
|
|
95
|
+
);
|
|
96
|
+
reports.set(image, report2);
|
|
97
|
+
scheduleTable();
|
|
98
|
+
};
|
|
99
|
+
const table = () => logReportTable(report(), options.console ?? "warnings");
|
|
100
|
+
const scheduleTable = () => {
|
|
101
|
+
if (options.console === "silent") return;
|
|
102
|
+
if (tableTimer) clearTimeout(tableTimer);
|
|
103
|
+
tableTimer = setTimeout(() => {
|
|
104
|
+
tableTimer = null;
|
|
105
|
+
const signature = reportSignature(report());
|
|
106
|
+
if (signature === lastTableSignature) return;
|
|
107
|
+
lastTableSignature = signature;
|
|
108
|
+
table();
|
|
109
|
+
}, 250);
|
|
110
|
+
};
|
|
111
|
+
const schedule = (image) => {
|
|
112
|
+
pending.add(image);
|
|
113
|
+
if (frame) return;
|
|
114
|
+
frame = requestAnimationFrame(() => {
|
|
115
|
+
frame = 0;
|
|
116
|
+
for (const pendingImage of pending) measure(pendingImage);
|
|
117
|
+
pending.clear();
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
const observe = (root = document) => {
|
|
121
|
+
resizeObserver = new ResizeObserver((entries) => {
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (entry.target instanceof HTMLImageElement) schedule(entry.target);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
const register = (image) => {
|
|
127
|
+
resizeObserver?.observe(image);
|
|
128
|
+
image.addEventListener("load", () => schedule(image), { once: true });
|
|
129
|
+
if (image.complete) schedule(image);
|
|
130
|
+
};
|
|
131
|
+
root.querySelectorAll(selector).forEach(register);
|
|
132
|
+
mutationObserver = new MutationObserver((records) => {
|
|
133
|
+
for (const record of records) {
|
|
134
|
+
for (const node of record.addedNodes) {
|
|
135
|
+
if (!(node instanceof Element)) continue;
|
|
136
|
+
if (node.matches(selector)) register(node);
|
|
137
|
+
node.querySelectorAll(selector).forEach(register);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
mutationObserver.observe(root, { childList: true, subtree: true });
|
|
142
|
+
if (typeof PerformanceObserver !== "undefined") {
|
|
143
|
+
performanceObserver = new PerformanceObserver((list) => {
|
|
144
|
+
for (const entry of list.getEntries()) {
|
|
145
|
+
const element = entry.element;
|
|
146
|
+
if (element) {
|
|
147
|
+
lcpElements.add(element);
|
|
148
|
+
if (element instanceof HTMLImageElement) schedule(element);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
performanceObserver.observe({
|
|
154
|
+
type: "largest-contentful-paint",
|
|
155
|
+
buffered: true
|
|
156
|
+
});
|
|
157
|
+
} catch {
|
|
158
|
+
performanceObserver = null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return api;
|
|
162
|
+
};
|
|
163
|
+
const disconnect = () => {
|
|
164
|
+
mutationObserver?.disconnect();
|
|
165
|
+
resizeObserver?.disconnect();
|
|
166
|
+
performanceObserver?.disconnect();
|
|
167
|
+
cancelAnimationFrame(frame);
|
|
168
|
+
if (tableTimer) clearTimeout(tableTimer);
|
|
169
|
+
tableTimer = null;
|
|
170
|
+
frame = 0;
|
|
171
|
+
pending.clear();
|
|
172
|
+
};
|
|
173
|
+
const report = () => [...reports.values()];
|
|
174
|
+
const startTimeline = (image) => {
|
|
175
|
+
timelines.set(image, {
|
|
176
|
+
blurMs: null,
|
|
177
|
+
previewMs: null,
|
|
178
|
+
finalMs: null,
|
|
179
|
+
startedAt: null,
|
|
180
|
+
previewAt: null,
|
|
181
|
+
finalAt: null
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
const markPreview = (image, preview, at = performance.now()) => {
|
|
185
|
+
const timing = timelines.get(image);
|
|
186
|
+
if (!timing) return;
|
|
187
|
+
timing.startedAt = resourceTiming(preview.currentSrc || preview.src)?.startTime ?? at;
|
|
188
|
+
timing.previewAt = at;
|
|
189
|
+
timing.blurMs = roundDuration(at - timing.startedAt);
|
|
190
|
+
schedule(image);
|
|
191
|
+
};
|
|
192
|
+
const markFinal = (image, at = performance.now()) => {
|
|
193
|
+
const timing = timelines.get(image);
|
|
194
|
+
if (!timing) return;
|
|
195
|
+
const finalStartedAt = resourceTiming(image.currentSrc || image.src)?.startTime ?? at;
|
|
196
|
+
timing.finalAt = at;
|
|
197
|
+
timing.finalMs = roundDuration(at - finalStartedAt);
|
|
198
|
+
if (timing.previewAt === null) {
|
|
199
|
+
timing.startedAt = finalStartedAt;
|
|
200
|
+
timing.blurMs = timing.finalMs;
|
|
201
|
+
} else timing.previewMs = roundDuration(at - timing.previewAt);
|
|
202
|
+
schedule(image);
|
|
203
|
+
};
|
|
204
|
+
const summary = () => ({
|
|
205
|
+
inspected: reports.size,
|
|
206
|
+
healthy: report().filter((item) => item.diagnostics.length === 0).length,
|
|
207
|
+
warnings: report().flatMap((item) => item.diagnostics).filter((item) => item.severity === "warning").length,
|
|
208
|
+
errors: report().flatMap((item) => item.diagnostics).filter((item) => item.severity === "error").length
|
|
209
|
+
});
|
|
210
|
+
const api = {
|
|
211
|
+
observe,
|
|
212
|
+
disconnect,
|
|
213
|
+
report,
|
|
214
|
+
table,
|
|
215
|
+
summary,
|
|
216
|
+
startTimeline,
|
|
217
|
+
markPreview,
|
|
218
|
+
markFinal
|
|
219
|
+
};
|
|
220
|
+
return api;
|
|
221
|
+
}
|
|
222
|
+
function roundDuration(value) {
|
|
223
|
+
return Math.round(value * 10) / 10;
|
|
224
|
+
}
|
|
225
|
+
function resourceTiming(url) {
|
|
226
|
+
const normalized = normalizeUrl(url);
|
|
227
|
+
const entries = performance.getEntriesByType(
|
|
228
|
+
"resource"
|
|
229
|
+
);
|
|
230
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
231
|
+
if (normalizeUrl(entries[index].name) === normalized) return entries[index];
|
|
232
|
+
}
|
|
233
|
+
return void 0;
|
|
234
|
+
}
|
|
235
|
+
function parseSrcSet(srcset, renderedWidth) {
|
|
236
|
+
return srcset.split(",").map((value) => value.trim().split(/\s+/)).map(([url, descriptor]) => ({
|
|
237
|
+
url,
|
|
238
|
+
width: descriptor?.endsWith("w") ? Number.parseInt(descriptor, 10) : descriptor?.endsWith("x") ? Math.round(Number.parseFloat(descriptor) * renderedWidth) : 0
|
|
239
|
+
})).filter((candidate) => candidate.url && candidate.width > 0);
|
|
240
|
+
}
|
|
241
|
+
function selectedCandidateWidth(selectedUrl, candidates) {
|
|
242
|
+
const selected = normalizeUrl(selectedUrl);
|
|
243
|
+
const candidate = candidates.find(
|
|
244
|
+
(item) => normalizeUrl(item.url) === selected
|
|
245
|
+
);
|
|
246
|
+
if (candidate) return candidate.width;
|
|
247
|
+
const queryWidth = new URL(
|
|
248
|
+
selectedUrl,
|
|
249
|
+
window.location.href
|
|
250
|
+
).searchParams.get("w");
|
|
251
|
+
return queryWidth ? Number(queryWidth) : null;
|
|
252
|
+
}
|
|
253
|
+
function normalizeUrl(url) {
|
|
254
|
+
try {
|
|
255
|
+
return new URL(url, window.location.href).href;
|
|
256
|
+
} catch {
|
|
257
|
+
return url;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function diagnoseRuntime(input) {
|
|
261
|
+
const diagnostics = [];
|
|
262
|
+
if (input.visualWidth !== void 0 && input.renderedWidth > 0 && Math.abs(input.visualWidth - input.renderedWidth) / input.renderedWidth > 0.02) {
|
|
263
|
+
diagnostics.push(
|
|
264
|
+
runtimeDiagnostic(
|
|
265
|
+
"css-transform-affects-visual-size",
|
|
266
|
+
"info",
|
|
267
|
+
"high",
|
|
268
|
+
"A CSS transform changes the visual width; responsive selection still uses the untransformed layout slot.",
|
|
269
|
+
{
|
|
270
|
+
renderedWidth: input.renderedWidth,
|
|
271
|
+
visualWidth: input.visualWidth
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (input.declaredSlotWidth && input.renderedWidth > 0) {
|
|
277
|
+
const pixelDifference = input.declaredSlotWidth - input.renderedWidth;
|
|
278
|
+
const difference = pixelDifference / input.renderedWidth;
|
|
279
|
+
const isMaterialMismatch = Math.abs(pixelDifference) >= 32;
|
|
280
|
+
if (difference < -0.1 && isMaterialMismatch) {
|
|
281
|
+
diagnostics.push(
|
|
282
|
+
runtimeDiagnostic(
|
|
283
|
+
"sizes-understates-layout",
|
|
284
|
+
"warning",
|
|
285
|
+
"high",
|
|
286
|
+
"The active sizes slot is smaller than the rendered CSS width.",
|
|
287
|
+
{
|
|
288
|
+
declaredSlotWidth: input.declaredSlotWidth,
|
|
289
|
+
renderedWidth: input.renderedWidth,
|
|
290
|
+
pixelDifference,
|
|
291
|
+
difference
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
);
|
|
295
|
+
} else if (difference > 0.25 && isMaterialMismatch) {
|
|
296
|
+
diagnostics.push(
|
|
297
|
+
runtimeDiagnostic(
|
|
298
|
+
"sizes-overstates-layout",
|
|
299
|
+
"warning",
|
|
300
|
+
"high",
|
|
301
|
+
"The active sizes slot is substantially larger than the rendered CSS width.",
|
|
302
|
+
{
|
|
303
|
+
declaredSlotWidth: input.declaredSlotWidth,
|
|
304
|
+
renderedWidth: input.renderedWidth,
|
|
305
|
+
pixelDifference,
|
|
306
|
+
difference
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (input.source && input.source.width < input.idealWidth) {
|
|
313
|
+
diagnostics.push(
|
|
314
|
+
runtimeDiagnostic(
|
|
315
|
+
"source-too-small",
|
|
316
|
+
"error",
|
|
317
|
+
"high",
|
|
318
|
+
"The source is smaller than the rendered width \xD7 DPR target.",
|
|
319
|
+
{
|
|
320
|
+
sourceWidth: input.source.width,
|
|
321
|
+
idealWidth: input.idealWidth
|
|
322
|
+
}
|
|
323
|
+
)
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
if (input.selectedWidth && input.selectedWidth < input.idealWidth) {
|
|
327
|
+
const hasLarger = input.availableWidths.some(
|
|
328
|
+
(width) => width >= input.idealWidth
|
|
329
|
+
);
|
|
330
|
+
diagnostics.push(
|
|
331
|
+
runtimeDiagnostic(
|
|
332
|
+
hasLarger ? "browser-downselected" : "missing-useful-candidate",
|
|
333
|
+
hasLarger ? "info" : "warning",
|
|
334
|
+
"high",
|
|
335
|
+
hasLarger ? "The browser selected below the theoretical target although a larger candidate exists." : "No available candidate reaches the theoretical target.",
|
|
336
|
+
{ selectedWidth: input.selectedWidth, idealWidth: input.idealWidth }
|
|
337
|
+
)
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (input.source && input.objectFit === "cover") {
|
|
341
|
+
const sourceRatio = input.source.width / input.source.height;
|
|
342
|
+
const renderedRatio = input.renderedWidth / input.renderedHeight;
|
|
343
|
+
if (Math.abs(sourceRatio - renderedRatio) / sourceRatio > 0.02) {
|
|
344
|
+
diagnostics.push(
|
|
345
|
+
runtimeDiagnostic(
|
|
346
|
+
"cropped-image",
|
|
347
|
+
"info",
|
|
348
|
+
"high",
|
|
349
|
+
"object-fit: cover crops part of the downloaded source.",
|
|
350
|
+
{
|
|
351
|
+
sourceAspectRatio: sourceRatio,
|
|
352
|
+
renderedAspectRatio: renderedRatio
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return diagnostics;
|
|
359
|
+
}
|
|
360
|
+
function resolveSizes(sizes) {
|
|
361
|
+
if (!sizes || sizes.trim() === "auto") return null;
|
|
362
|
+
for (const rule of splitTopLevel(sizes)) {
|
|
363
|
+
const value = rule.trim();
|
|
364
|
+
let length = value;
|
|
365
|
+
if (value.startsWith("(")) {
|
|
366
|
+
const end = matchingParenthesis(value);
|
|
367
|
+
if (end < 0) continue;
|
|
368
|
+
if (!matchMedia(value.slice(0, end + 1)).matches) continue;
|
|
369
|
+
length = value.slice(end + 1).trim();
|
|
370
|
+
}
|
|
371
|
+
if (!length || length === "auto") continue;
|
|
372
|
+
const probe = document.createElement("div");
|
|
373
|
+
probe.style.cssText = `position:fixed;visibility:hidden;pointer-events:none;width:${length};height:0`;
|
|
374
|
+
document.body.append(probe);
|
|
375
|
+
const width = probe.getBoundingClientRect().width;
|
|
376
|
+
probe.remove();
|
|
377
|
+
return Number.isFinite(width) && width > 0 ? width : null;
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
function splitTopLevel(value) {
|
|
382
|
+
const output = [];
|
|
383
|
+
let depth = 0;
|
|
384
|
+
let start = 0;
|
|
385
|
+
for (let index = 0; index < value.length; index++) {
|
|
386
|
+
if (value[index] === "(") depth++;
|
|
387
|
+
if (value[index] === ")") depth--;
|
|
388
|
+
if (value[index] === "," && depth === 0) {
|
|
389
|
+
output.push(value.slice(start, index));
|
|
390
|
+
start = index + 1;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
output.push(value.slice(start));
|
|
394
|
+
return output;
|
|
395
|
+
}
|
|
396
|
+
function matchingParenthesis(value) {
|
|
397
|
+
let depth = 0;
|
|
398
|
+
for (let index = 0; index < value.length; index++) {
|
|
399
|
+
if (value[index] === "(") depth++;
|
|
400
|
+
if (value[index] === ")" && --depth === 0) return index;
|
|
401
|
+
}
|
|
402
|
+
return -1;
|
|
403
|
+
}
|
|
404
|
+
function runtimeDiagnostic(code, severity, confidence, message, evidence) {
|
|
405
|
+
return { code, severity, confidence, message, evidence };
|
|
406
|
+
}
|
|
407
|
+
function logReportTable(reports, mode) {
|
|
408
|
+
if (mode === "silent") return;
|
|
409
|
+
const visible = reports.filter(
|
|
410
|
+
(report) => mode === "all" || report.diagnostics.some((diagnostic) => diagnostic.severity !== "info")
|
|
411
|
+
);
|
|
412
|
+
if (visible.length === 0) return;
|
|
413
|
+
console.table(
|
|
414
|
+
visible.map((report) => ({
|
|
415
|
+
asset: report.id,
|
|
416
|
+
stage: imageStage(report),
|
|
417
|
+
rendered: `${Math.round(report.renderedWidth)}\xD7${Math.round(report.renderedHeight)}`,
|
|
418
|
+
selected: report.selectedWidth,
|
|
419
|
+
ideal: report.idealWidth,
|
|
420
|
+
bytes: report.transferBytes,
|
|
421
|
+
blurMs: report.timing?.blurMs ?? null,
|
|
422
|
+
previewMs: report.timing?.previewMs ?? null,
|
|
423
|
+
finalMs: report.timing?.finalMs ?? null,
|
|
424
|
+
lazy: report.loading === "lazy",
|
|
425
|
+
lcp: report.isLcp,
|
|
426
|
+
status: diagnosticStatus(report),
|
|
427
|
+
diagnostics: report.diagnostics.map((diagnostic) => diagnostic.code).join(", ")
|
|
428
|
+
}))
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
function imageStage(report) {
|
|
432
|
+
if (report.timing?.finalAt !== null && report.timing?.finalAt !== void 0)
|
|
433
|
+
return "final";
|
|
434
|
+
if (report.timing?.previewAt) return "preview";
|
|
435
|
+
if (report.timing) return "blur";
|
|
436
|
+
return report.naturalWidth > 0 ? "final" : "loading";
|
|
437
|
+
}
|
|
438
|
+
function diagnosticStatus(report) {
|
|
439
|
+
if (report.diagnostics.some((item) => item.severity === "error"))
|
|
440
|
+
return "error";
|
|
441
|
+
if (report.diagnostics.some((item) => item.severity === "warning"))
|
|
442
|
+
return "warning";
|
|
443
|
+
if (report.diagnostics.some((item) => item.severity === "info")) return "info";
|
|
444
|
+
return "healthy";
|
|
445
|
+
}
|
|
446
|
+
function reportSignature(reports) {
|
|
447
|
+
return reports.map(
|
|
448
|
+
(report) => [
|
|
449
|
+
report.id,
|
|
450
|
+
imageStage(report),
|
|
451
|
+
report.selectedWidth,
|
|
452
|
+
report.transferBytes,
|
|
453
|
+
report.timing?.blurMs,
|
|
454
|
+
report.timing?.previewMs,
|
|
455
|
+
report.timing?.finalMs,
|
|
456
|
+
diagnosticStatus(report)
|
|
457
|
+
].join(":")
|
|
458
|
+
).join("|");
|
|
459
|
+
}
|
|
460
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
461
|
+
0 && (module.exports = {
|
|
462
|
+
createIrisDebugger,
|
|
463
|
+
diagnoseRuntime,
|
|
464
|
+
inspectImageElement
|
|
465
|
+
});
|
|
466
|
+
//# sourceMappingURL=browser.cjs.map
|