diff --git a/.chachalog/img7Kq2Ls.md b/.chachalog/img7Kq2Ls.md
index da1cfd31..dd0d5589 100644
--- a/.chachalog/img7Kq2Ls.md
+++ b/.chachalog/img7Kq2Ls.md
@@ -5,4 +5,4 @@ javascript-modules: minor
Added an image API for rendering content images: a `JImage` component, plus `getImageProps` and `buildImageUrl` for cases that need the data or just a URL. (#746)
-Declare how the image sits in the page — `` — and the library sizes the file to the slot, offers the browser alternatives for high-density and narrow screens, reserves the space so the layout does not shift while it loads, and refreshes cached pages when an editor replaces the picture. Alternative text is now required, so a missing one is caught while you write the view rather than by an accessibility audit later. A new [Rendering Images](https://academy.jahia.com/documentation/jahia-cms/jahia-8-2/developer/javascript-module-development/images) guide explains which setups actually resize images, and which serve the original.
+Declare how the image sits in the page — `` — and the library sizes the file to the slot, offers the browser alternatives for high-density and narrow screens, reserves the space so the layout does not shift while it loads, and refreshes cached pages when an editor replaces the picture. Alternative text is now required, so a missing one is caught while you write the view rather than by an accessibility audit later. A new [Rendering Images](https://academy.jahia.com/documentation/jahia-cms/jahia-8-2/developer/javascript-module-development/images) guide explains which setups actually resize images, and which serve the original.
diff --git a/.chachalog/img9Fv3Rt.md b/.chachalog/img9Fv3Rt.md
new file mode 100644
index 00000000..6a3730fe
--- /dev/null
+++ b/.chachalog/img9Fv3Rt.md
@@ -0,0 +1,12 @@
+---
+# Allowed version bumps: patch, minor, major
+javascript-modules: minor
+---
+
+Made the image API usable on a fluid site: a slot described by its own `sizes`, first-class `sizes="auto"`, full `
` attribute pass-through, a pluggable loader, and CSS background and absolute URLs. (#766)
+
+A slot is described **once**, and the candidate files follow that one description. `` when the markup knows the slot's width in CSS pixels; `` when only CSS knows it — a percentage, a grid cell, an aspect-ratio box, a slot constrained by its height, which on a fluid design is most of them. Writing both is a type error, because the two used to disagree in silence: the `sizes` was emitted while the candidates were still derived from the `slotWidth`. `layout="fixed"` takes no `sizes` at all, its whole meaning being that the slot is one number, and `fill` is now only for the image positioned _over_ a parent that owns the box.
+
+The component forwards every `
` attribute it does not compute itself, including `width` and `height`, which are required together so that an image takes its whole box from the markup or none of it, and an open `attributes` map carries anything else, including a value derived from the resolved image. A project that speaks its own URL dialect supplies a `loader`, with `quality` and `unoptimized`, per call or once per module with `setImageDefaults`. Outside the component, `getImageProps` also takes a `fallback` and now requires the render context, `buildBackgroundImageUrl` returns a ready CSS `url(…)` value, `buildImageUrl` registers the same cache dependency the component does, and `absolute` builds the URLs that `og:image`, canonical links and JSON-LD need — on `buildNodeUrl` too, so links get it as well.
+
+The slot width is now `slotWidth`, freeing `width` for the HTML attribute it always looked like; the image that loads first is marked `preload` rather than `priority`, following `next/image` 16; and the data `getImageProps` returns is typed `ImgProps`, leaving `JImageProps` to mean the component's own props.
diff --git a/docs/1-getting-started/4-making-a-blog/README.md b/docs/1-getting-started/4-making-a-blog/README.md
index 28712c08..b9686511 100644
--- a/docs/1-getting-started/4-making-a-blog/README.md
+++ b/docs/1-getting-started/4-making-a-blog/README.md
@@ -56,7 +56,7 @@ jahiaComponent(
({ "jcr:title": title, subtitle, authors, cover }: Props, { currentNode }) => {
return (
-
+
diff --git a/docs/2-guides/8-images/README.md b/docs/2-guides/8-images/README.md
index b8ccfe82..13f0db00 100644
--- a/docs/2-guides/8-images/README.md
+++ b/docs/2-guides/8-images/README.md
@@ -14,97 +14,254 @@ Content images come from the JCR, and rendering one well means more than pointin
```tsx
import { JImage, jahiaComponent } from "@jahia/javascript-modules-library";
import type { JCRNodeWrapper } from "org.jahia.services.content";
+import placeholder from "./placeholder.jpg";
jahiaComponent(
{ nodeType: "example:article", componentType: "view" },
({ title, cover }: { title: string; cover?: JCRNodeWrapper }) => (
{title}
-
+
),
);
```
-That renders a `
` with a `src` sized for the slot, a `srcSet` of alternatives the browser can pick from, a matching `sizes`, the image's intrinsic `width` and `height` so the layout does not shift when it loads, `loading="lazy"`, and a registered cache dependency on the image node.
+That renders an `
` with a `src` sized for the slot, a `srcSet` of alternatives the browser can pick from, a `sizes` the browser resolves against the real box, the intrinsic dimensions that reserve its space, `loading="lazy"`, and a registered cache dependency on the image node.
-## Declare the layout, not the numbers
+Three things are worth knowing before anything else:
-The one number you provide is `width`: how wide the image's slot is, in CSS pixels. How that slot behaves is the `layout`:
+- **You describe the slot exactly once.** Either `slotWidth`, when the markup knows the slot's width in CSS pixels, or `sizes`, when only CSS knows it — never both. `sizes="auto"` lets the browser measure the real box, and on a fluid design that is most slots.
+- **`alt` is required.** An image that carries no information of its own — a decorative flourish, or one that only repeats an adjacent caption — is declared with `alt=""`. That is a deliberate statement, not a shortcut, and it is why the prop has no default.
+- **`fallback`** is a static asset of your module, rendered when the content property is empty. Without one, a missing `node` renders nothing at all rather than a broken image. Roughly a third of real call sites want it.
-| `layout` | Meaning | Use for |
-| ----------------------- | ------------------------------------------- | ------------------------------------------ |
-| `constrained` (default) | at most `width`, shrinks with the viewport | content in a column, cards in a fluid grid |
-| `fixed` | always exactly `width` | avatars, logos, fixed-size thumbnails |
-| `full-width` | always the viewport width; needs no `width` | heroes, full-bleed banners |
+## Pick the spelling your slot actually has
+
+Two questions, in this order.
+
+**Does anything in the markup know how wide the slot is, in CSS pixels?** If it does, say the number with `slotWidth`. If it does not — the width comes from a `%`, a `fr`, a `rem`, a grid track, a flex line — then the only honest description is a `sizes` string, and you write that instead.
+
+**Is the image in the normal flow, or stretched over a parent that owns the box?** Everything in the normal flow is the default layout; an image positioned over its parent is `layout="fill"`.
+
+A slot is described **once**. `slotWidth` and `sizes` are two descriptions of the same box, and nothing can reconcile them, so writing both is a type error. The candidate files follow whichever one you wrote — that is what picking one is for.
+
+| Your slot | Write |
+| ---------------------------------------------------------------------- | ----------------------------------------------------------------------- |
+| a real number of CSS pixels, always — an avatar, a fixed logo slot | `layout="fixed" slotWidth={80}` |
+| the viewport — a hero, a full-bleed banner | `layout="full-width"` |
+| a max-width container: at most N pixels, the full viewport below that | `slotWidth={400}` |
+| a grid cell, or a column that changes at every breakpoint | `sizes="(min-width: 64rem) 33vw, 100vw"` |
+| an `aspect-ratio` box — `.card { aspect-ratio: 16 / 9 }` | `sizes="auto"`, or the string that describes the column the box sits in |
+| positioned _over_ a parent that owns the box, cropped under an overlay | `layout="fill" sizes="auto"` |
+| height-constrained — `.logo-strip img { height: 3rem; width: auto }` | `sizes="auto"`, with the caveat [below](#the-height-constrained-slot) |
+| the box is the `width`/`height` attributes, with no CSS at all | `layout="fixed" slotWidth={48} width={48} height={48}` |
```tsx
-
-
+
+
+
+
+
+
```
-Everything else follows from that. `constrained` and `fixed` ask for the slot width and its 2× variant, so a high-density screen gets a sharp file; `constrained` and `full-width` also ask for the smaller sizes a narrow viewport can use. Candidates are always capped by the original — Jahia never upscales — and the original itself is only offered when it is close to the largest size actually requested, so an 8000-pixel master is never sent to fill a 640-pixel card.
+`slotWidth` is deliberately not called `width`: it is how much room the layout gives the image, in CSS pixels, while `width` is the HTML attribute that ends up in the markup. They are different numbers with different jobs, and the component accepts both — which is why the last row states them both. The attributes reserve the space; they do not describe the slot, because CSS can still resize the element they are on.
+
+### The `aspect-ratio` box
+
+An **`aspect-ratio` box** (`.card { aspect-ratio: 16 / 9 }`) states a height-to-width relation and never a width. Whatever width the layout gives the box, the height follows. So the box is described by the _column_ it sits in: `sizes="(min-width: 64rem) 33vw, 100vw"` if you know that column, and `sizes="auto"` if you do not. Declaring a `slotWidth` your CSS contradicts is worse than declaring none — the browser is then told about a slot that does not exist.
+
+### The height-constrained slot
+
+A **height-constrained slot** (`.logo-strip img { height: 3rem; width: auto }`) states a height, and its width is the height times _that asset's_ aspect ratio. **No framework has a precise answer for this**, ours included: `sizes` is a statement about widths, and the browser resolves it before it knows anything about the file it is going to fetch. Astro converts the height into a width through the aspect ratio and then emits a `sizes` about the viewport's width, which is a different quantity; Next's width computation takes no height at all, and its `fill` layout rejects a caller height other than `100%`.
+
+What to write:
+
+- `sizes="auto"` is the honest answer, and the accurate one. The browser measures the real box after layout, so the height constraint is already applied by the time it picks a file. It only works on a lazily loaded image, which a logo strip below the fold is anyway.
+- If the image must load eagerly — it is the LCP element — compute the width yourself, from the CSS height and the asset's own ratio, and state it as a pixel `sizes`:
+
+ ```tsx
+ const cssHeight = 48; // .logo-strip img { height: 3rem }
+ const { intrinsicWidth = cssHeight, intrinsicHeight = cssHeight } = readImageMeta(logo);
+ const slot = Math.round((cssHeight * intrinsicWidth) / intrinsicHeight);
+
+ ;
+ ```
+
+ That is exact for the one asset, and it is why the library will not do it for you: it is your CSS height, not something the markup states.
+
+## What actually resizes the image, and where
+
+This is the part that surprises people, and it is worth knowing before you go looking for a bug: **a plain Jahia instance does not resize images on request.** The size travels differently depending on where the asset lives, and `buildImageUrl` reports which channel it used.
+
+| Channel | When | Resizes? |
+| ----------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `loader` | the call, or the module, supplies a `loader` | Up to that loader — the library stops guessing |
+| `provider` | the asset is mounted from an external provider (a DAM such as Keepeek or Cloudinary) | Yes — the provider's decorator builds a signed, transformed URL |
+| `thumbnail` | the requested width matches a thumbnail Jahia pre-generated (150px, 350px) | Yes, and this is the only one that works with no extra infrastructure |
+| `query` | anything else on the default provider: the size becomes `?w=` / `?h=` | Only behind [Media Optimization](https://academy.jahia.com/documentation/jahia-cms/jahia-8-2/developer/optional-features/media-optimization-cloudimage) (Jahia Cloud, live mode). Elsewhere the file servlet ignores the parameters and returns the original bytes |
+| `original` | vectors, `unoptimized`, and any request that matches the original size | Nothing to do |
+
+So on your local instance, a `srcSet` full of `?w=` candidates is expected, and every one of them returns the same file. Nothing is broken: the markup is correct, and it starts saving bytes the moment the site runs somewhere that honours the hint. If you want to see real per-width files locally, mount a DAM, request a thumbnail width, or write a [loader](#your-own-url-dialect-loader).
-If you genuinely need exact control, `widths` (candidate widths, in **image** pixels) and `sizes` (a raw [sizes attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#sizes)) override the derivation. Reach for them last: hand-written values are the part of responsive images that goes stale when a layout changes.
+An instance in development mode says so rather than letting you discover it: the first image that falls back to `?w=` candidates prints one warning naming that image as its example and pointing back at this section. It says the same thing for every image, so it is printed once per instance and never in production.
-### Two different widths: the slot and the file
+### When Jahia never measured the image
-The number you give is the **slot**: how much room the image gets in the layout, in CSS pixels. The numbers in `srcSet` are **files**: how many actual pixels each candidate contains. They are not the same thing, and that is the whole reason `srcSet` exists.
+Everything the library derives from the intrinsic size — capping candidates at the original, the `width`/`height` pair that reserves the space, and the `loading="lazy"` that pair makes safe — comes from two JCR properties, **`j:width` and `j:height`**. Jahia's image extractor writes them when the file is uploaded, and an asset that arrived another way (an import, a provider mount, an extractor that failed) can carry neither.
+
+Nothing about the resulting markup is invalid, which is why nobody notices. A development instance prints one warning per such image, naming it. The fix is on the content side — re-upload the file, or run the extractor over it — and until then you can state the box yourself with `width` and `height`.
+
+## Two different widths: the slot and the file
+
+The number in `slotWidth` is the **slot**: how much room the image gets in the layout, in CSS pixels. The numbers in `srcSet` are **files**: how many actual pixels each candidate contains. They are not the same thing, and that is the whole reason `srcSet` exists.
A slot of 400 CSS pixels needs a 400-pixel file on an ordinary screen and an 800-pixel one on a phone with a 2× display. A slot that says "up to 400, less on a narrow screen" needs smaller files too. So one slot maps to _several_ useful file sizes, and the browser is the only party that knows which one to fetch — it is the only one that knows the viewport and the pixel density at the moment the page loads.
The **candidate ladder** is the list of file widths offered for the layouts where the slot is not a single number:
-| `layout` | Files offered | Uses the ladder |
-| ------------- | ---------------------------------------------------------- | ------------------------------------------------------------- |
-| `fixed` | `width`, `2 × width` | no — the slot is one number, so two files cover it |
-| `constrained` | ladder entries below `width`, then `width` and `2 × width` | yes, for the narrow viewports where the image shrinks |
-| `full-width` | the whole ladder | yes — the slot is the viewport, which varies from phone to 4K |
+| Layout | Files offered | Uses the ladder |
+| --------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------- |
+| `fixed` + `slotWidth` | `slotWidth`, `2 × slotWidth` | no — the slot is one number, so two files cover it |
+| `constrained` + `slotWidth` | ladder entries up to `2 × slotWidth`, then `slotWidth` and its double | yes, for the narrow viewports where the image shrinks |
+| `full-width` | the whole ladder | yes — the slot is the viewport, which varies from phone to 4K |
+| any layout + `sizes` | the ladder entries the `sizes` string asks for | yes — the string is the only description, so it decides |
+
+The default ladder is `[320, 640, 960, 1280, 1920, 2560]` — doubling-ish steps, because a candidate only pays for itself if it is meaningfully smaller than the next one up. Override it per call with `breakpoints`, or once for the whole module with [`setImageDefaults`](#module-wide-defaults).
+
+Two consequences worth knowing. Candidates stop at `2 × slotWidth`: a 3× file costs roughly twice the bytes of a 2× one for a difference few people can see, so a 3× phone gets the 2× file. And the ladder starts at 320: below that, a device asks for the 320-pixel file and scales it down, which is the right trade for the handful of viewports that narrow.
+
+### When the ladder comes from your `sizes`
-The default ladder is `[320, 640, 960, 1280, 1920, 2560]` — doubling-ish steps, because a candidate only pays for itself if it is meaningfully smaller than the next one up. Override it per call with `breakpoints` if a layout needs a different shape.
+A `sizes` string is a description of the slot, so the candidates are derived from it and not from anything else. Each entry's source size is read — its media condition is skipped, so the `1024px` in `(min-width: 1024px) 33vw` is never mistaken for a slot width — and the two ends of the ladder stand in for the two ends of the viewport range. The narrowest slot the string can describe becomes the floor, the widest becomes the ceiling, and the ladder keeps every entry from the floor up to and including the first one that reaches **twice** the ceiling, so the widest slot is still sharp on a 2× display.
-Two consequences worth knowing. Candidates stop at `2 × width`: a 3× file costs roughly twice the bytes of a 2× one for a difference few people can see, so a 3× phone gets the 2× file. And the ladder starts at 320: below that, a device asks for the 320-pixel file and scales it down, which is the right trade for the handful of viewports that narrow.
+`vw`, `px`, decimals and the `calc()` / `min()` / `max()` / `clamp()` functions are all read. Anything the parser cannot read — `auto`, a `%`, an `em`, a malformed string — falls back to **the whole ladder**, never to a narrow one: a ladder that is too wide costs a few bytes of markup, where one that is too narrow ships images the browser has to upscale, and says nothing about it.
-So: you declare the slot, the library enumerates the files, the browser chooses. You never compute a file width by hand unless you reach for `widths`.
+Candidates are always capped by the original — Jahia never upscales — and the original itself is only offered when it is close to the largest size actually requested, so an 8000-pixel master is never sent to fill a 640-pixel card.
+
+`widths` (candidate widths, in **image** pixels) overrides the ladder when you know better. It replaces the ladder, not the slot: the slot still has to be described, because that is what `sizes` is built from.
### Why two attributes at all
-`srcSet` lists files with their widths (`photo.jpg?w=640 640w`). `sizes` tells the browser how much space the image will occupy _before_ layout happens (`(min-width: 400px) 400px, 100vw`). The browser divides one by the other, multiplies by the screen's device pixel ratio, and downloads the smallest file that still looks sharp. Get `sizes` wrong — or omit it — and the browser assumes the image fills the viewport and downloads far more than it needs. That is the arithmetic `layout` exists to do for you.
+`srcSet` lists files with their widths (`photo.jpg?w=640 640w`). `sizes` tells the browser how much space the image will occupy (`(min-width: 400px) 400px, 100vw`, or `auto` to measure it). The browser divides one by the other, multiplies by the screen's device pixel ratio, and downloads the smallest file that still looks sharp. Get `sizes` wrong — or omit it — and the browser assumes the image fills the viewport and downloads far more than it needs. That is the arithmetic `layout` exists to do for you.
+
+### `sizes="auto"`
+
+`sizes="auto"` asks the browser to measure the real box after layout, which beats any value you could derive from the markup. It is the usual answer whenever only CSS knows the slot, and one thing follows from the specification: **`auto` is only read on a lazily loaded image**, so `` sets `loading="lazy"` for you. Since nothing in the string describes the slot, the candidates are the whole ladder.
+
+That makes `auto` and `preload` contradictory. They still meet in practice, because they come from different layers — a shared wrapper defaults every image to `sizes="auto"`, a leaf view marks this one as the page's LCP element — and neither layer can see the other. The eager load wins, `auto` becomes `100vw`, and a development instance prints one warning naming the image. `100vw` is the safe, wasteful answer and the only one left: a slot spelled with `sizes` carries no width to derive a better one from. To get a better value, describe the slot yourself: `sizes="(min-width: 60rem) 33vw, 100vw"`.
+
+## The `fill` layout: an image positioned over its parent
+
+`fill` is not the ordinary in-flow case. It takes the image **out of the normal flow** and stretches it over the nearest positioned ancestor, which is what you want when the parent owns the box and the image is decoration inside it: a card cover under a text overlay, a banner cropped with `object-fit`.
-## Above the fold: `priority`
+It is the one layout that carries CSS of its own, and it only works if the parent cooperates:
+
+```tsx
+
+
+
{title}
+
+```
+
+```css
+/* The parent must be positioned, and must have a height of its own — the image no longer
+ contributes one, because it is absolutely positioned. */
+.frame {
+ position: relative;
+ aspect-ratio: 16 / 9;
+}
+
+.cover {
+ object-fit: cover;
+}
+```
+
+Two consequences. Since nothing in the document says how wide that parent is, `sizes` is required. And the intrinsic `width`/`height` are deliberately not emitted: they would state a box that fights the parent's. Your own `style` still wins over the positioning the component applies.
+
+If the parent has no reason to be positioned, drop `layout="fill"` and keep the `sizes`: an ordinary image in the normal flow, described the same way.
+
+## Above the fold: `preload`
An image is lazy-loaded by default, which is wrong for the one image that is already on screen when the page opens — usually the largest, and the one the browser measures as [Largest Contentful Paint](https://web.dev/articles/lcp).
```tsx
-
+
```
-`priority` loads it eagerly and at high fetch priority. Use it on one image per page.
+`preload` loads it eagerly and at high fetch priority. Use it on one image per page. It overrides `sizes="auto"`, for the reason given [above](#sizesauto).
+
+## Every `
` attribute still reaches the element
-## Alternative text is required
+`JImage` accepts everything an `
` accepts, minus the two attributes it computes (`src` and `srcSet`). `className`, `style`, `id`, `decoding`, `referrerPolicy`, `crossOrigin`, `usemap`, every `aria-*` — they are passed straight through, and the list is derived from the component's own props, so a prop added later cannot silently swallow one.
+
+`width` and `height` are the HTML attributes. Write them and they win over the intrinsic dimensions, which is how an image takes its box from the markup and needs no CSS rule at all:
+
+```tsx
+
+```
+
+**They are required together.** Two of them state one box, and half of yours with half of ours would state a wrong aspect ratio — so the component takes both or neither, and TypeScript says so at the call site. This is also the guard against the easiest mistake in this API: writing `width={400}` when you meant `slotWidth={400}`.
+
+For anything React's typings do not model — `data-*` above all — there is `attributes`, spread onto the element last. It takes a record, or a function of the image the library resolved:
+
+```tsx
+
+ ({ "data-track-src": src, "data-track-width": width })}
+/>
+```
+
+## Alternative text
`alt` is not optional, because a missing one is invisible until someone using a screen reader hits it. Describe what the image shows, in the page's language:
```tsx
-
+
```
-An image that carries no information of its own — a decorative flourish, or one that only repeats an adjacent caption — is declared with `alt=""`. That is a deliberate statement, not a shortcut.
+## A placeholder while it loads
-## What actually resizes the image, and where
+`placeholder="blur"` paints the smallest thumbnail Jahia pre-generated under the image, as a `background-image`, so a large photo shows something immediately instead of a blank box. The browser scaling a 150-pixel file up is what produces the blur.
+
+```tsx
+
+
+```
-This is the part that surprises people: **a plain Jahia instance does not resize images on request.** The size travels differently depending on where the asset lives, and `buildImageUrl` reports which channel it used.
+Two limits worth knowing. The placeholder is **not removed once the image has loaded** — the component renders on the server and there is no client-side code to clear it — so it stays behind a transparent PNG. And an image Jahia has generated no thumbnail for simply gets no placeholder, rather than an error.
-| Channel | When | Resizes? |
-| ----------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `provider` | the asset is mounted from an external provider (a DAM such as Keepeek or Cloudinary) | Yes — the provider's decorator builds a signed, transformed URL |
-| `thumbnail` | the requested width matches a thumbnail Jahia pre-generated (150px, 350px) | Yes, and this is the only one that works with no extra infrastructure |
-| `query` | anything else on the default provider: the size becomes `?w=` / `?h=` | Only behind [Media Optimization](https://academy.jahia.com/documentation/jahia-cms/jahia-8-2/developer/optional-features/media-optimization-cloudimage) (Jahia Cloud, live mode). Elsewhere the file servlet ignores the parameters and returns the original bytes |
-| `original` | vectors, and any request that matches the original size | Nothing to do |
+`placeholder` covers the loading gap; `fallback` covers the missing node. They are different problems and can be used together.
+
+## Your own URL dialect: `loader`
+
+A project on a CDN, a custom DAM, or a Media Optimization setup that speaks a different dialect replaces the routing entirely. A loader is given the asset's own URL, the candidate width and the requested quality, and returns the URL to use:
-So on your local instance, a `srcSet` full of `?w=` candidates is expected, and every one of them returns the same file. Nothing is broken: the markup is correct, and it starts saving bytes the moment the site runs somewhere that honours the hint. If you want to see real per-width files locally, mount a DAM or request a thumbnail width.
+```tsx
+const cloudinary = ({ src, width, quality }) =>
+ `https://res.cloudinary.com/acme/image/fetch/f_auto,q_${quality ?? "auto"},w_${width}/${src}`;
+
+;
+```
-An instance in development mode says so rather than letting you discover it: the first image that falls back to `?w=` candidates prints one warning naming that image as its example and pointing back at this section. It says the same thing for every image, so it is printed once per instance and never in production. Silence it by making the resize real — a thumbnail width, a DAM mount, or Media Optimization in front of the instance — or leave it, because locally it is only telling you what this table already says.
+`quality` is passed to the loader. Without one it rides the channel that already carries hints — `?q=` on `query`, one more decorator argument on `provider` — and is dropped on `thumbnail` and `original`, which are fixed renditions with nothing to act on.
+
+`unoptimized` opts a single image out of all of it: the original bytes, no candidates, no `srcSet`.
+
+### Module-wide defaults
+
+Setting the loader on every call site is how it drifts. `setImageDefaults` sets it once for the module, at the top level of a server file:
+
+```tsx
+import { setImageDefaults } from "@jahia/javascript-modules-library";
+
+setImageDefaults({ loader: cloudinary, quality: 80, breakpoints: [480, 960, 1440] });
+```
+
+Any single call can still override any of them. The defaults belong to **your** module: every JavaScript module in an instance shares one JavaScript context, and these are keyed so that yours never reaches anybody else's images.
## Images inside an island
@@ -112,28 +269,59 @@ An island's props are serialized, so a React element cannot be one of them, and
```tsx
// gallery.server.tsx
-import { getImageProps, Island } from "@jahia/javascript-modules-library";
+import { getImageProps, Island, useServerContext } from "@jahia/javascript-modules-library";
-const images = photos.map((photo) => getImageProps(photo, { alt: title, width: 800 }));
+function GalleryView({ photos, title }) {
+ const context = useServerContext();
+ const images = photos.map((photo) =>
+ getImageProps(photo, { alt: title, slotWidth: 800 }, context),
+ );
-;
+ return ;
+}
```
```tsx
// Gallery.client.tsx
-import type { ImageProps } from "@jahia/javascript-modules-library";
+import type { ImgProps } from "@jahia/javascript-modules-library";
-export default function Gallery({ images }: { images: ImageProps[] }) {
+export default function Gallery({ images }: { images: ImgProps[] }) {
const [current, setCurrent] = useState(0);
return
setCurrent((i) => i + 1)} />;
}
```
-`ImageProps` is plain, serializable data, and `alt` is required there too.
+The `context` is not optional: it carries the render cache dependency and it names the module whose `setImageDefaults` apply, so a call without it silently loses both. Inside a view it comes from `useServerContext()`.
+
+`ImgProps` is plain, serializable data — the type of what comes _out_ of `getImageProps`, as opposed to `JImageProps`, the component's own props. `alt` is required there too. It carries `loading: "lazy"` when `sizes` resolved to `auto`; spread the whole object rather than picking fields out of it, or that pairing is lost.
+
+`getImageProps` accepts a missing node, like the component does: with a `fallback` it returns the fallback's props, and without one it returns `null`.
+
+## Background images
+
+CSS needs a `url()` value, not an `
`. `buildBackgroundImageUrl` returns one, with the same routing, the same clamping, the same cache dependency, and the escaping a stylesheet needs:
+
+```tsx
+
+```
+
+A background has no `srcSet`, so ask for the largest size the slot can reach and let the clamp cut it down. Density is `image-set()`, and that one is yours to write.
+
+## Absolute URLs
+
+`og:image`, `og:url`, a canonical link and JSON-LD all need a URL with a scheme and a host. `absolute` produces one, on `buildNodeUrl` and on every image function:
+
+```tsx
+buildNodeUrl(page, { absolute: true });
+buildImageUrl(cover, { width: 1200 }, { absolute: true }).url;
+getImageProps(cover, { alt: title, sizes: "auto", absolute: true }, context);
+```
+
+The host is the **target site's** server name, not the current request's — a link to a page of another site must name that site's server. A site with no server name configured falls back to the request's own scheme, host and port, which is what makes this work on a local instance. When neither is right — a reverse proxy, a preview host, a canonical domain — name the origin yourself: `absolute: "https://www.example.com"`.
## Cache dependencies
-`JImage` and `getImageProps` register a render cache dependency on the image node, so replacing the image in jContent flushes the fragments that display it. If you build URLs yourself with `buildImageUrl`, register it yourself:
+`JImage`, `getImageProps`, `buildImageUrl` and `buildBackgroundImageUrl` all register a render cache dependency on the image node, so replacing the image in jContent flushes the fragments that display it. Pass `cacheDependency: false` only when you register it yourself:
```tsx
server.render.addCacheDependency({ node: imageNode }, renderContext);
@@ -141,7 +329,11 @@ server.render.addCacheDependency({ node: imageNode }, renderContext);
## Reference
-- `JImage` — the component; renders an unstyled `
`, so pass a `className`. Server-side only.
-- `getImageProps(node, options)` — the same props as plain data, for islands and for cases where you own the element.
-- `buildImageUrl(node, size)` — one URL and the channel that carried the size.
-- `readImageMeta(node)` — mime type and intrinsic dimensions, if you need them directly.
+- `JImage` — the component. Renders an unstyled `
`, except where the feature is styling (`fill`, `placeholder`). Server-side only.
+- `getImageProps(node, options, context)` — the same props as plain data, for islands and for cases where you own the element.
+- `buildImageUrl(node, size, options)` — one URL and the channel that carried the size.
+- `buildBackgroundImageUrl(node, size, options)` — a CSS `url("…")` value.
+- `buildThumbnailUrl(node)` — the smallest thumbnail Jahia pre-generated, or `undefined`.
+- `inspectImageChannel(node, width, options)` — which channel a given width would take.
+- `setImageDefaults({ loader, quality, unoptimized, breakpoints })` — the module's defaults.
+- `readImageMeta(node)` — mime type and intrinsic dimensions, if you need them directly (`og:image:width`, for instance).
diff --git a/javascript-modules-library/src/components/JImage.spec.tsx b/javascript-modules-library/src/components/JImage.spec.tsx
new file mode 100644
index 00000000..5e64a8ae
--- /dev/null
+++ b/javascript-modules-library/src/components/JImage.spec.tsx
@@ -0,0 +1,358 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { JCRNodeWrapper } from "org.jahia.services.content";
+
+// `JImage` is a plain function of its props: rendering it through React would only add a tree to
+// walk back down. The one hook it calls is the server context, which outside the engine is ours.
+const serverContext = () => ({
+ bundleKey: "test-module",
+ renderContext: {
+ getRequest: () => ({ getContextPath: () => "" }),
+ getResponse: () => ({ encodeURL: (url: string) => url }),
+ getURLGenerator: () => ({ getCurrentModule: () => "/modules/test-module" }),
+ },
+});
+vi.mock("../hooks/useServerContext.js", () => ({ useServerContext: serverContext }));
+vi.mock("../hooks/useServerContext", () => ({ useServerContext: serverContext }));
+
+// A render context means a cache dependency, which the engine's `server` bridge registers
+Reflect.set(globalThis, "server", { render: { addCacheDependency: () => {} } });
+
+const { JImage } = await import("./JImage.js");
+
+/** A JCR file node holding an image, with just the surface the image code touches. */
+const imageNode = ({
+ url = "/files/photo.jpg",
+ width = 4000,
+ height = 2000,
+ thumbnails = ["thumbnail", "thumbnail2"],
+}: { url?: string; width?: number; height?: number; thumbnails?: string[] } = {}) =>
+ ({
+ getPath: () => "/sites/test/files/photo.jpg",
+ getProvider: () => ({ isDefault: () => true }),
+ getResolveSite: () => ({ getServerName: () => "www.example.com" }),
+ getUrl: () => url,
+ getThumbnailUrl: (name: string) => {
+ if (!thumbnails.includes(name)) throw new Error(`no thumbnail ${name}`);
+ return `${url}?t=${name}`;
+ },
+ getNode: (child: string) =>
+ child === "jcr:content" ? { getPropertyAsString: () => "image/jpeg" } : null,
+ getProperty: (property: string) => {
+ const value = property === "j:width" ? width : property === "j:height" ? height : undefined;
+ if (value === undefined) throw new Error(`no such property: ${property}`);
+ return { getLong: () => value };
+ },
+ }) as unknown as JCRNodeWrapper;
+
+/** The attributes the component would put on the `
`. */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const attributesOf = (element: ReturnType): Record =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (element as any).props;
+
+describe("attribute pass-through", () => {
+ it("forwards an
attribute the component has no opinion about", () => {
+ const props = attributesOf(
+ JImage({
+ node: imageNode(),
+ alt: "A terrace",
+ slotWidth: 600,
+ id: "cover",
+ className: "cover",
+ decoding: "async",
+ referrerPolicy: "no-referrer",
+ }),
+ );
+ expect(props).toMatchObject({
+ id: "cover",
+ className: "cover",
+ decoding: "async",
+ referrerPolicy: "no-referrer",
+ });
+ });
+
+ it("emits the height attribute, which a caller may need as the box itself", () => {
+ const props = attributesOf(
+ JImage({ node: imageNode(), alt: "", slotWidth: 48, width: 48, height: 48 }),
+ );
+ expect(props).toMatchObject({ width: 48, height: 48 });
+ });
+
+ it("reserves space from the intrinsic pair when the caller writes neither", () => {
+ const props = attributesOf(JImage({ node: imageNode(), alt: "", slotWidth: 600 }));
+ expect(props).toMatchObject({ width: 4000, height: 2000, loading: "lazy" });
+ });
+
+ it("refuses half a box, and names the prop the caller probably meant", () => {
+ // TypeScript rejects this spelling; the throw catches the JavaScript caller and the spread
+ // @ts-expect-error `width` and `height` are required together
+ expect(() => JImage({ node: imageNode(), alt: "", slotWidth: 600, width: 48 })).toThrow(
+ /pass both or neither.*slotWidth/s,
+ );
+ });
+
+ it("takes the caller's box over the intrinsic one", () => {
+ const props = attributesOf(
+ JImage({ node: imageNode(), alt: "", slotWidth: 600, width: 48, height: 24 }),
+ );
+ expect(props).toMatchObject({ width: 48, height: 24, loading: "lazy" });
+ });
+});
+
+describe("the attributes map", () => {
+ it("spreads a record onto the element", () => {
+ const props = attributesOf(
+ JImage({
+ node: imageNode(),
+ alt: "",
+ slotWidth: 600,
+ attributes: { "data-testid": "cover", "itemProp": "image" },
+ }),
+ );
+ expect(props).toMatchObject({ "data-testid": "cover", "itemProp": "image" });
+ });
+
+ it("hands the resolved image to the function form", () => {
+ const props = attributesOf(
+ JImage({
+ node: imageNode(),
+ alt: "",
+ slotWidth: 600,
+ attributes: ({ src, width }) => ({ "data-src": src, "data-width": width }),
+ }),
+ );
+ // The resolved image, not the props the caller wrote: `src` is the smallest candidate and
+ // `width` the intrinsic one
+ expect(props["data-src"]).toBe("/files/photo.jpg?w=320");
+ expect(props["data-width"]).toBe(4000);
+ });
+
+ it("is applied last, so it can override anything the component computed", () => {
+ const props = attributesOf(
+ JImage({ node: imageNode(), alt: "", slotWidth: 600, attributes: { loading: "eager" } }),
+ );
+ expect(props.loading).toBe("eager");
+ });
+});
+
+describe("preload", () => {
+ it("loads the image eagerly and at high priority", () => {
+ const props = attributesOf(
+ JImage({ node: imageNode(), alt: "", layout: "full-width", preload: true }),
+ );
+ expect(props).toMatchObject({ loading: "eager", fetchPriority: "high" });
+ });
+});
+
+describe('sizes="auto"', () => {
+ it("loads lazily, because that is the only mode a browser reads it in", () => {
+ const props = attributesOf(
+ JImage({ node: imageNode(), alt: "", layout: "fill", sizes: "auto" }),
+ );
+ expect(props).toMatchObject({ sizes: "auto", loading: "lazy" });
+ });
+
+ it("gives way to preload, which a shared wrapper's default cannot argue with", () => {
+ const props = attributesOf(
+ JImage({ node: imageNode(), alt: "", sizes: "auto", preload: true }),
+ );
+ // A slot spelled with `sizes` carries no width to derive a replacement from, so the safe,
+ // wasteful answer is all that is left
+ expect(props.sizes).toBe("100vw");
+ expect(props).toMatchObject({ loading: "eager", fetchPriority: "high" });
+ });
+
+ it("replaces auto on an eagerly loaded image too, not only on a preloaded one", () => {
+ const props = attributesOf(
+ JImage({ node: imageNode(), alt: "", sizes: "auto", loading: "eager" }),
+ );
+ expect(props.sizes).toBe("100vw");
+ expect(props.loading).toBe("eager");
+ });
+
+ it("leaves a slot stated in CSS pixels alone, which never asked for auto", () => {
+ const props = attributesOf(
+ JImage({ node: imageNode(), alt: "", slotWidth: 600, loading: "eager" }),
+ );
+ expect(props.sizes).toBe("(min-width: 600px) 600px, 100vw");
+ expect(props.loading).toBe("eager");
+ });
+});
+
+describe("the development warnings", () => {
+ /** The engine injects `server` as a global; a test provides only the parts under test. */
+ const stubDevelopmentMode = (developmentMode: boolean) => {
+ Reflect.set(globalThis, "server", {
+ render: { addCacheDependency: () => {} },
+ config: { isDevelopmentMode: () => developmentMode },
+ });
+ };
+
+ /** Each warning is printed once per engine lifetime, so each test needs its own module copy. */
+ let freshJImage: typeof JImage;
+ beforeEach(async () => {
+ vi.resetModules();
+ ({ JImage: freshJImage } = await import("./JImage.js"));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ Reflect.set(globalThis, "server", { render: { addCacheDependency: () => {} } });
+ });
+
+ /** The `?w=` candidates warn on this instance too, so each test reads only its own message. */
+ const autoSizesMessages = (warn: { mock: { calls: unknown[][] } }): string[] =>
+ warn.mock.calls.map(([message]) => String(message)).filter((m) => m.includes('sizes="auto"'));
+
+ it('reports the image that asked for both sizes="auto" and an eager load', () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ stubDevelopmentMode(true);
+
+ freshJImage({ node: imageNode(), alt: "", sizes: "auto", preload: true });
+
+ const [message, ...rest] = autoSizesMessages(warn);
+ expect(rest).toHaveLength(0);
+ expect(message).toContain("/sites/test/files/photo.jpg");
+ expect(message).toContain('sizes="100vw"');
+ });
+
+ it("says nothing in production, where the page still renders", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ stubDevelopmentMode(false);
+
+ const props = attributesOf(
+ freshJImage({ node: imageNode(), alt: "", sizes: "auto", preload: true }),
+ );
+
+ expect(warn).not.toHaveBeenCalled();
+ expect(props.sizes).toBe("100vw");
+ });
+
+ it("says nothing when the two never met", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ stubDevelopmentMode(true);
+
+ freshJImage({ node: imageNode(), alt: "", sizes: "auto" });
+ freshJImage({ node: imageNode(), alt: "", layout: "full-width", preload: true });
+
+ expect(autoSizesMessages(warn)).toHaveLength(0);
+ });
+});
+
+describe('the "fill" layout', () => {
+ it("positions the image over its parent, which markup cannot express", () => {
+ const props = attributesOf(
+ JImage({ node: imageNode(), alt: "", layout: "fill", sizes: "50vw" }),
+ );
+ expect(props.style).toMatchObject({ position: "absolute", inset: 0, width: "100%" });
+ expect(props.width).toBeUndefined();
+ });
+
+ it("lets a caller's style win over the positioning it suggests", () => {
+ const props = attributesOf(
+ JImage({
+ node: imageNode(),
+ alt: "",
+ layout: "fill",
+ sizes: "50vw",
+ style: { position: "fixed", objectFit: "cover" },
+ }),
+ );
+ expect(props.style).toMatchObject({ position: "fixed", objectFit: "cover" });
+ });
+
+ it("loads lazily on the strength of the parent's box", () => {
+ const props = attributesOf(
+ JImage({ node: imageNode(), alt: "", layout: "fill", sizes: "50vw" }),
+ );
+ expect(props.loading).toBe("lazy");
+ });
+});
+
+describe("placeholder", () => {
+ it('paints the smallest Jahia thumbnail under a "blur" image', () => {
+ const props = attributesOf(
+ JImage({ node: imageNode(), alt: "", slotWidth: 600, placeholder: "blur" }),
+ );
+ expect(props.style).toMatchObject({
+ backgroundImage: 'url("/files/photo.jpg?t=thumbnail")',
+ backgroundSize: "cover",
+ });
+ });
+
+ it("prefers a data URI the caller supplied", () => {
+ const props = attributesOf(
+ JImage({
+ node: imageNode(),
+ alt: "",
+ slotWidth: 600,
+ placeholder: "blur",
+ blurDataURL: "data:image/png;base64,AAAA",
+ }),
+ );
+ expect(props.style.backgroundImage).toBe('url("data:image/png;base64,AAAA")');
+ });
+
+ it("takes a data URI as the placeholder value itself", () => {
+ const props = attributesOf(
+ JImage({
+ node: imageNode(),
+ alt: "",
+ slotWidth: 600,
+ placeholder: "data:image/gif;base64,BBBB",
+ }),
+ );
+ expect(props.style.backgroundImage).toBe('url("data:image/gif;base64,BBBB")');
+ });
+
+ it("paints nothing when Jahia generated no thumbnail", () => {
+ const props = attributesOf(
+ JImage({
+ node: imageNode({ thumbnails: [] }),
+ alt: "",
+ slotWidth: 600,
+ placeholder: "blur",
+ }),
+ );
+ expect(props.style).toBeUndefined();
+ });
+
+ it("leaves the style alone by default", () => {
+ const props = attributesOf(JImage({ node: imageNode(), alt: "", slotWidth: 600 }));
+ expect(props.style).toBeUndefined();
+ });
+});
+
+describe("a slot described by its sizes", () => {
+ it("carries no CSS of its own: it is an ordinary image in the normal flow", () => {
+ const props = attributesOf(JImage({ node: imageNode(), alt: "", sizes: "auto" }));
+ expect(props.style).toBeUndefined();
+ // Unlike `fill`, the intrinsic pair survives, and it is what reserves the space
+ expect(props).toMatchObject({ width: 4000, height: 2000, loading: "lazy" });
+ });
+
+ it("refuses the slot described twice, in the markup and in the string", () => {
+ expect(() =>
+ // @ts-expect-error slotWidth and sizes are two descriptions of one slot
+ JImage({
+ node: imageNode(),
+ alt: "",
+ slotWidth: 400,
+ sizes: "(min-width: 1024px) 33vw, 100vw",
+ }),
+ ).toThrow(/takes a slotWidth or a sizes, never both/);
+ });
+});
+
+describe("a missing node", () => {
+ it("renders the module asset offered as a fallback", () => {
+ const props = attributesOf(
+ JImage({ alt: "Nothing yet", slotWidth: 400, fallback: "img/placeholder.jpg" }),
+ );
+ expect(props.src).toBe("/modules/test-module/img/placeholder.jpg");
+ });
+
+ it("renders nothing at all when there is no fallback either", () => {
+ expect(JImage({ alt: "", slotWidth: 400 })).toBeNull();
+ });
+});
diff --git a/javascript-modules-library/src/components/JImage.tsx b/javascript-modules-library/src/components/JImage.tsx
index 4c96ae52..671005f0 100644
--- a/javascript-modules-library/src/components/JImage.tsx
+++ b/javascript-modules-library/src/components/JImage.tsx
@@ -1,23 +1,127 @@
-import type { ImgHTMLAttributes, JSX } from "react";
+import type { CSSProperties, ImgHTMLAttributes, JSX } from "react";
import type { JCRNodeWrapper } from "org.jahia.services.content";
import { useServerContext } from "../hooks/useServerContext.js";
-import { getImageProps, type ImageLayout } from "../utils/image/getImageProps.js";
-import { buildModuleFileUrl } from "../utils/urlBuilder/urlBuilder.js";
+import { buildThumbnailUrl, cssUrl } from "../utils/image/buildImageUrl.js";
+import { warnAutoSizesEager } from "../utils/image/devWarnings.js";
+import {
+ getImageProps,
+ isAutoSizes,
+ type ImageSlot,
+ type ImgProps,
+} from "../utils/image/getImageProps.js";
+import type { ImageSourceOptions } from "../utils/image/imageDefaults.js";
+
+/**
+ * Attributes spread onto the `
` last, after everything the component computed.
+ *
+ * A record covers the static ones — which a plain JSX attribute already expresses. The function
+ * form is for a value that depends on the image the library resolved: an analytics attribute
+ * carrying the final `src`, a test hook naming the width actually served.
+ */
+export type ExtraImageAttributes =
+ | Record
+ | ((image: ImgProps) => Record);
+
+/**
+ * The `width` and `height` HTML attributes, which together state one box — and therefore travel
+ * together. Writing only one used to be legal and quietly dropped the other, which is also how a
+ * call site written against `width` as the _slot_ width type-checked and then failed at render.
+ */
+export type MarkupBox =
+ | { width: number | `${number}`; height: number | `${number}` }
+ | { width?: undefined; height?: undefined };
+
+/**
+ * What `JImage` decides for itself.
+ *
+ * The HTML attributes it accepts are everything an `
` takes _minus these_ — derived, not
+ * hand-listed, so adding a prop here can never silently swallow an attribute that used to reach the
+ * element. Only `src` and `srcSet` are additionally withheld: the component computes them.
+ */
+export interface JImageBaseProps extends ImageSourceOptions {
+ /** The file node holding the image. When missing, `fallback` is rendered instead. */
+ node?: JCRNodeWrapper | null;
+ /** Alternative text; `""` declares the image decorative. */
+ alt: string;
+ /** Explicit candidate widths in image pixels. Overrides the ladder the layout would derive. */
+ widths?: number[];
+ /** Candidate ladder used by every layout but `fixed`. */
+ breakpoints?: readonly number[];
+ /**
+ * Register a render cache dependency on the image node.
+ *
+ * @default true
+ */
+ cacheDependency?: boolean;
+ /**
+ * Marks the image as the page's largest above-the-fold element: it loads eagerly and at high
+ * fetch priority instead of being lazy-loaded. Use it on one image per page.
+ */
+ preload?: boolean;
+ /**
+ * A module static asset (`import placeholder from "/static/img/placeholder.jpg"`) rendered when
+ * `node` is missing, so an unfilled content property does not leave a broken image.
+ */
+ fallback?: string;
+ /**
+ * A low-quality image shown underneath while the real one downloads: `"blur"` uses the smallest
+ * thumbnail Jahia pre-generated, and a `data:image/…` value is used as given.
+ *
+ * @default "empty"
+ */
+ placeholder?: "blur" | "empty" | `data:image/${string}`;
+ /** The `placeholder="blur"` source, when the Jahia thumbnail is not the one you want. */
+ blurDataURL?: string;
+ /** Spread onto the `
` last. */
+ attributes?: ExtraImageAttributes;
+ /**
+ * The `width` HTML attribute. Overrides the intrinsic width — with `height`, this is how an image
+ * takes its box from the markup and needs no CSS rule at all.
+ *
+ * Required together with `height`: half of yours and half of ours would state a wrong aspect
+ * ratio. The room the _layout_ gives the image is `slotWidth`, a different number with a
+ * different job.
+ */
+ width?: number | `${number}`;
+ /** The `height` HTML attribute. Overrides the intrinsic height; see {@link JImageBaseProps.width}. */
+ height?: number | `${number}`;
+}
+
+/**
+ * What `JImage` takes: its own props plus the slot description, which is one of the shapes
+ * {@link ImageSlot} allows and never a mix of two.
+ */
+export type JImageProps = JImageBaseProps & ImageSlot;
+
+/** The layout that has to be CSS, because "fills its parent" is not something markup can say. */
+const FILL_STYLE: CSSProperties = {
+ position: "absolute",
+ inset: 0,
+ width: "100%",
+ height: "100%",
+};
/**
* Renders a JCR image as an `
`: resized `src`, `srcSet` candidates, the matching `sizes`, the
* intrinsic dimensions that reserve its space, and a render cache dependency on the image node.
*
- * Declare how the image sits in the page — `layout` plus the slot `width` — rather than computing
- * candidate widths by hand. The element carries no styling of its own: pass a `className`.
+ * Describe the slot once — a `slotWidth` when the markup states its width in CSS pixels, a `sizes`
+ * when only CSS knows it — rather than computing candidate widths by hand. On a fluid design most
+ * slots are the second kind, and `sizes="auto"` lets the browser measure the real box.
+ *
+ * The element carries no styling of its own, except where the feature _is_ styling: `layout="fill"`
+ * positions it over its parent, and `placeholder` paints a background. Anything else is your
+ * `className`, and any `style` you pass wins over both.
*
* Server-side only, because it registers the cache dependency. A client component receives image
* data instead: build it with {@link getImageProps} and pass it through ``.
*
* @example
* ```tsx
- *
- *
+ *
+ *
+ *
+ *
* ```;
*
* @returns The `
` element.
@@ -26,72 +130,120 @@ export function JImage({
node,
alt,
layout,
- width,
+ slotWidth,
widths,
sizes,
- priority = false,
+ breakpoints,
+ cacheDependency,
+ loader,
+ quality,
+ unoptimized,
+ absolute,
+ preload = false,
fallback,
+ placeholder = "empty",
+ blurDataURL,
+ attributes,
+ width,
+ height,
loading,
fetchPriority,
+ style,
...imgAttributes
}: Readonly<
- {
- /** The file node holding the image. When missing, `fallback` is rendered instead. */
- node?: JCRNodeWrapper | null;
- /** Alternative text; `""` declares the image decorative. */
- alt: string;
- /**
- * How the image occupies its slot.
- *
- * @default "constrained"
- */
- layout?: ImageLayout;
- /** The slot width in CSS pixels. Required by the `constrained` and `fixed` layouts. */
- width?: number;
- /** Explicit candidate widths in image pixels. Escape hatch: prefer `layout` + `width`. */
- widths?: number[];
- /**
- * Marks the image as the page's largest above-the-fold element: it loads eagerly, at high
- * priority, instead of being lazy-loaded.
- */
- priority?: boolean;
- /**
- * A module static asset (`import placeholder from "/static/img/placeholder.jpg"`) rendered when
- * `node` is missing, so an unfilled content property does not leave a broken image.
- */
- fallback?: string;
- } & Omit, "src" | "srcSet" | "width" | "height" | "alt">
->): JSX.Element | null {
+ JImageBaseProps & Omit, "src" | "srcSet" | keyof JImageProps>
+> &
+ MarkupBox &
+ // Outside the `Readonly<>`, so that the union stays a union: a mapped type over it would collapse
+ // `slotWidth` and `sizes` back into two independent optionals and let a call site write both
+ ImageSlot): JSX.Element | null {
const context = useServerContext();
- const props = node
- ? getImageProps(node, { alt, layout, width, widths, sizes }, context)
- : fallback
- ? { src: buildModuleFileUrl(fallback, {}, context), alt: alt.trim() }
- : null;
+ // Caught here rather than at the `
`, because the call site that writes one of the two is
+ // usually one that meant `slotWidth` — and TypeScript already rejects that spelling
+ if ((width === undefined) !== (height === undefined)) {
+ throw new Error(
+ "JImage: width and height are the HTML attributes and state one box, so pass both or " +
+ "neither. If you meant how much room the layout gives the image, that is slotWidth.",
+ );
+ }
+
+ // `sizes="auto"` is only read on a lazily loaded image. Loading it eagerly does not degrade to
+ // "the browser measures the box anyway": it degrades to 100vw, which downloads the largest
+ // candidate on every screen. The two props legitimately come from different layers — a shared
+ // wrapper defaults every image to `auto`, a leaf view marks this one as the LCP element — so the
+ // eager load wins over the default, and `auto` gives way to the safe, wasteful answer — the only
+ // one left, since a slot spelled with `sizes` carries no width for the library to derive one from.
+ const eagerness = preload ? "preload" : loading === "eager" ? 'loading="eager"' : undefined;
+ const autoOverridden = eagerness !== undefined && isAutoSizes(sizes);
+ const requestedSizes = autoOverridden ? "100vw" : sizes;
+
+ const image: ImgProps | null = getImageProps(
+ node,
+ {
+ alt,
+ widths,
+ breakpoints,
+ cacheDependency,
+ loader,
+ quality,
+ unoptimized,
+ absolute,
+ fallback,
+ // The union was enforced at the call site; here the three are plain optionals again, and
+ // `getImageProps` checks them once more for the caller that reached it without types
+ ...({ layout, slotWidth, sizes: requestedSizes } as ImageSlot),
+ },
+ context,
+ );
+
+ if (!image) return null;
+
+ if (autoOverridden && node) warnAutoSizesEager(node, eagerness, image.sizes);
+
+ // The caller owns the box or the library does; a mix of the two states a wrong aspect ratio
+ const markupSized = width !== undefined;
+ const renderedWidth = markupSized ? width : image.width;
+ const renderedHeight = markupSized ? height : image.height;
+
+ const autoSizes = isAutoSizes(image.sizes);
- if (!props) return null;
+ // Lazy loading without reserved space causes layout shift, so it is only safe once the space is
+ // known — from the intrinsic dimensions, from the ones the caller wrote, or from the positioned
+ // parent a `fill` image is stretched over.
+ const spaceReserved =
+ layout === "fill" || (renderedWidth !== undefined && renderedHeight !== undefined);
- const { width: intrinsicWidth, height: intrinsicHeight } = props as {
- width?: number;
- height?: number;
- };
+ const placeholderUrl =
+ placeholder === "empty"
+ ? undefined
+ : placeholder === "blur"
+ ? (blurDataURL ?? (node ? buildThumbnailUrl(node, { absolute, context }) : undefined))
+ : placeholder;
return (
);
}
diff --git a/javascript-modules-library/src/index.ts b/javascript-modules-library/src/index.ts
index 4962a52c..80287107 100644
--- a/javascript-modules-library/src/index.ts
+++ b/javascript-modules-library/src/index.ts
@@ -9,7 +9,13 @@ export { AbsoluteArea } from "./components/AbsoluteArea.js";
export { AddContentButtons } from "./components/AddContentButtons.js";
export { AddResources } from "./components/AddResources.js";
export { Area } from "./components/Area.js";
-export { JImage } from "./components/JImage.js";
+export {
+ JImage,
+ type ExtraImageAttributes,
+ type JImageBaseProps,
+ type JImageProps,
+ type MarkupBox,
+} from "./components/JImage.js";
// Declaration and registration
export { jahiaComponent } from "./framework/jahiaComponent.js";
@@ -29,14 +35,18 @@ export {
buildEndpointUrl,
buildNodeUrl,
buildModuleFileUrl,
+ type AbsoluteUrlOption,
} from "./utils/urlBuilder/urlBuilder.js";
// Images
export {
+ buildBackgroundImageUrl,
buildImageUrl,
+ buildThumbnailUrl,
THUMBNAIL_WIDTHS,
type ImageResizeChannel,
type ImageUrl,
+ type ImageUrlOptions,
} from "./utils/image/buildImageUrl.js";
export {
getImageProps,
@@ -44,8 +54,18 @@ export {
DEFAULT_BREAKPOINTS,
type ImageLayout,
type ImageOptions,
- type ImageProps,
+ type ImageOptionsBase,
+ type ImageSlot,
+ type ImgProps,
} from "./utils/image/getImageProps.js";
+export {
+ setImageDefaults,
+ type ImageContext,
+ type ImageDefaults,
+ type ImageLoader,
+ type ImageLoaderProps,
+ type ImageSourceOptions,
+} from "./utils/image/imageDefaults.js";
export { readImageMeta, type ImageMeta } from "./utils/image/imageMeta.js";
// I18n
diff --git a/javascript-modules-library/src/utils/image/buildImageUrl.ts b/javascript-modules-library/src/utils/image/buildImageUrl.ts
index 8896fa4d..c36eb79c 100644
--- a/javascript-modules-library/src/utils/image/buildImageUrl.ts
+++ b/javascript-modules-library/src/utils/image/buildImageUrl.ts
@@ -1,6 +1,11 @@
import type { JCRNodeWrapper } from "org.jahia.services.content";
-import type { RenderContext, Resource } from "org.jahia.services.render";
+import { toAbsoluteUrl } from "../urlBuilder/absoluteUrl.js";
import { buildNodeUrl } from "../urlBuilder/urlBuilder.js";
+import {
+ resolveImageDefaults,
+ type ImageContext,
+ type ImageSourceOptions,
+} from "./imageDefaults.js";
import { clampToIntrinsic, readImageMeta, type ImageMeta } from "./imageMeta.js";
/**
@@ -8,7 +13,9 @@ import { clampToIntrinsic, readImageMeta, type ImageMeta } from "./imageMeta.js"
* named outcome rather than a URL that silently returns the original bytes.
*
* - `original`: no resize was requested, or the request was a no-op (it matched the intrinsic size),
- * so the untouched asset URL is returned.
+ * or `unoptimized` opted the image out, so the untouched asset URL is returned.
+ * - `loader`: a {@link ImageLoader} owns the URL. Whether it resizes is that loader's business, and
+ * the library stops guessing.
* - `provider`: the size was handed to the node's own provider through `node.getUrl(["w:600"])`. An
* external provider mount (a DAM: Keepeek, Cloudinary…) decorates that call into a signed,
* transformed URL; whether a given provider honours every dimension is up to its decorator.
@@ -20,7 +27,7 @@ import { clampToIntrinsic, readImageMeta, type ImageMeta } from "./imageMeta.js"
* {@link https://academy.jahia.com/documentation/jahia-cms/jahia-8-2/developer/optional-features/media-optimization-cloudimage Media Optimization}
* is what interprets them.
*/
-export type ImageResizeChannel = "original" | "provider" | "thumbnail" | "query";
+export type ImageResizeChannel = "original" | "loader" | "provider" | "thumbnail" | "query";
/** A URL, plus how the requested size actually reached the image. */
export interface ImageUrl {
@@ -30,6 +37,21 @@ export interface ImageUrl {
width?: number;
}
+/** What every URL-building call accepts, on top of the resize options it defines itself. */
+export interface ImageUrlOptions extends ImageSourceOptions {
+ /** Pre-read metadata, so a caller building several candidates reads `j:width` once. */
+ meta?: ImageMeta;
+ /** Provided by React context on the server; pass one when calling outside a render. */
+ context?: ImageContext;
+ /**
+ * Register a render cache dependency on the image node, so that replacing the image in jContent
+ * flushes the fragments that display it. Turn it off only when the caller registers it itself.
+ *
+ * @default true
+ */
+ cacheDependency?: boolean;
+}
+
/**
* Widths of Jahia's pre-generated thumbnails, in image pixels.
*
@@ -56,56 +78,94 @@ const isDefaultProvider = (node: JCRNodeWrapper): boolean => {
}
};
-/** The thumbnail whose width matches the request exactly, if Jahia generated one. */
-const matchingThumbnail = (node: JCRNodeWrapper, width: number): string | undefined => {
- const thumbnail = THUMBNAILS.find((candidate) => candidate.width === width);
- if (!thumbnail) return undefined;
-
+/** A thumbnail Jahia generated for this node, by name. */
+const thumbnailUrl = (node: JCRNodeWrapper, name: string): string | undefined => {
try {
- return node.getThumbnailUrl(thumbnail.name) || undefined;
+ return node.getThumbnailUrl(name) || undefined;
} catch {
return undefined;
}
};
+/** The thumbnail whose width matches the request exactly, if Jahia generated one. */
+const matchingThumbnail = (node: JCRNodeWrapper, width: number): string | undefined => {
+ const thumbnail = THUMBNAILS.find((candidate) => candidate.width === width);
+ return thumbnail && thumbnailUrl(node, thumbnail.name);
+};
+
+/**
+ * Registers the render cache dependency on the image node.
+ *
+ * Silent without a render context: a call outside a render has nothing to invalidate, and refusing
+ * would make `buildImageUrl` unusable from a script or a test.
+ */
+const registerCacheDependency = (node: JCRNodeWrapper, context: ImageContext | undefined): void => {
+ const renderContext = context?.renderContext;
+ if (renderContext) server.render.addCacheDependency({ node }, renderContext);
+};
+
/**
* Builds the URL of a JCR image, resized to the requested dimensions.
*
* The requested size is clamped to the image's intrinsic size, and a resize that would be a no-op
* returns the original URL. Which channel carries the size depends on where the asset lives — see
* {@link ImageResizeChannel}; the chosen one is reported so callers (and the images guide) can be
- * explicit about what a given environment will actually do.
+ * explicit about what a given environment will actually do. A module that speaks its own URL
+ * dialect replaces the routing with a {@link ImageLoader}.
+ *
+ * Registers a render cache dependency on the node, like `getImageProps` does — a CSS background
+ * image built here is flushed when an editor replaces the picture.
*
* @param node - The file node holding the image.
* @param size - The requested size in image pixels. Omit to get the original.
- * @param options - Pre-read metadata (avoids re-reading `j:width` per candidate) and the render
- * context, both optional.
+ * @param options - Loader, quality, absolute URLs, pre-read metadata and the render context.
* @returns The URL and the channel that carried the size.
* @see {@link getImageProps} to build a full set of `
` props, including `srcSet`.
+ * @see {@link buildBackgroundImageUrl} for a ready-to-use CSS `url(…)` value.
*/
export function buildImageUrl(
node: JCRNodeWrapper,
size?: { width?: number; height?: number },
- options?: {
- meta?: ImageMeta;
- context?: { renderContext?: RenderContext; currentResource?: Resource };
- },
+ options?: ImageUrlOptions,
): ImageUrl {
const meta = options?.meta ?? readImageMeta(node);
const context = options?.context;
+ const absolute = options?.absolute;
+ const { loader, quality, unoptimized } = resolveImageDefaults(options, context);
+
+ if (options?.cacheDependency ?? true) registerCacheDependency(node, context);
+
const original = (): ImageUrl => ({
- url: buildNodeUrl(node, {}, context),
+ url: toAbsoluteUrl(buildNodeUrl(node, {}, context), node, absolute, context),
channel: "original",
});
// A vector is resolution-independent: resizing it server-side is meaningless
if (meta.vector) return original();
+ // The caller wants these bytes, whatever a channel or a CDN would have made of them
+ if (unoptimized) return original();
const width =
size?.width === undefined ? undefined : clampToIntrinsic(size.width, meta.intrinsicWidth);
const height =
size?.height === undefined ? undefined : clampToIntrinsic(size.height, meta.intrinsicHeight);
+ // A loader owns the URL, including the decision to serve the same bytes at every width — so it
+ // is called even when the requested width matches the original, which is where a CDN still does
+ // its format negotiation. It only speaks widths, so a height-only request is not for it.
+ if (loader && width !== undefined) {
+ return {
+ url: toAbsoluteUrl(
+ loader({ src: buildNodeUrl(node, {}, context), width, quality }),
+ node,
+ absolute,
+ context,
+ ),
+ channel: "loader",
+ width,
+ };
+ }
+
const noopWidth = width === undefined || width === meta.intrinsicWidth;
const noopHeight = height === undefined || height === meta.intrinsicHeight;
if (noopWidth && noopHeight) return original();
@@ -116,9 +176,11 @@ export function buildImageUrl(
url: buildNodeUrl(
node,
{
+ absolute,
args: {
...(noopWidth ? {} : { w: width! }),
...(noopHeight ? {} : { h: height! }),
+ ...(quality === undefined ? {} : { q: quality }),
},
},
context,
@@ -128,19 +190,28 @@ export function buildImageUrl(
};
}
- // A pre-generated thumbnail is a real, offline resize — prefer it over a hint nothing may honour
+ // A pre-generated thumbnail is a real, offline resize — prefer it over a hint nothing may honour.
+ // It is a fixed rendition, so a quality hint has nothing to act on and is dropped.
if (noopHeight && width !== undefined) {
const thumbnail = matchingThumbnail(node, width);
- if (thumbnail) return { url: thumbnail, channel: "thumbnail", width };
+ if (thumbnail) {
+ return {
+ url: toAbsoluteUrl(thumbnail, node, absolute, context),
+ channel: "thumbnail",
+ width,
+ };
+ }
}
return {
url: buildNodeUrl(
node,
{
+ absolute,
parameters: {
...(noopWidth ? {} : { w: String(width) }),
...(noopHeight ? {} : { h: String(height) }),
+ ...(quality === undefined ? {} : { q: String(quality) }),
},
},
context,
@@ -150,5 +221,72 @@ export function buildImageUrl(
};
}
+/**
+ * Commas are legal inside a URL but they separate items in both of the places an image URL travels
+ * to — the `srcSet` candidate list and the `background-image` layer list — and Jahia's srcset
+ * rewriter splits on every one of them, corrupting for instance a Cloudinary transformation URL
+ * (`…/upload/f_auto,w_600/…`). Percent-encoding them keeps every reader happy.
+ *
+ * @see {@link https://github.com/Jahia/jahia/issues/23}
+ */
+export const commaSafe = (url: string): string => url.replaceAll(",", "%2C");
+
+/**
+ * Builds a CSS `url("…")` value for a JCR image, ready to drop into `background-image`.
+ *
+ * The same routing, clamping and cache dependency as {@link buildImageUrl}, plus the escaping a
+ * stylesheet needs: the URL is quoted, and its commas, quotes and backslashes are percent-encoded.
+ *
+ * A background image has no `srcSet`, so ask for the largest size the slot can reach and let the
+ * clamp cut it down; `image-set()` is the CSS answer to density, and it is the caller's to write.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```;
+ *
+ * @param node - The file node holding the image.
+ * @param size - The requested size in image pixels. Omit to get the original.
+ * @param options - The same options {@link buildImageUrl} takes.
+ * @returns A CSS value such as `url("/files/photo.jpg?w=1920")`.
+ */
+export function buildBackgroundImageUrl(
+ node: JCRNodeWrapper,
+ size?: { width?: number; height?: number },
+ options?: ImageUrlOptions,
+): string {
+ return cssUrl(buildImageUrl(node, size, options).url);
+}
+
+/** Wraps a URL in a CSS `url()` value, neutralising everything a stylesheet parser reads. */
+export const cssUrl = (url: string): string => {
+ // A `data:` URI travels no rewriter, and the comma in `data:image/png;base64,…` separates the
+ // header from the payload — encoding that one would destroy the image rather than protect it.
+ const escaped = url.startsWith("data:") ? url : commaSafe(url);
+ return `url("${escaped.replaceAll("\\", "%5C").replaceAll('"', "%22")}")`;
+};
+
+/**
+ * The URL of the smallest thumbnail Jahia pre-generated for this node.
+ *
+ * The one image variant a plain instance produces offline, which makes it the low-quality
+ * placeholder source that costs no new infrastructure.
+ *
+ * @param node - The file node holding the image.
+ * @param options - Absolute URLs and the render context.
+ * @returns The thumbnail URL, or `undefined` when Jahia generated none (a vector, a fresh upload,
+ * an external provider that exposes no thumbnails).
+ */
+export function buildThumbnailUrl(
+ node: JCRNodeWrapper,
+ options?: Pick,
+): string | undefined {
+ for (const { name } of THUMBNAILS) {
+ const url = thumbnailUrl(node, name);
+ if (url) return toAbsoluteUrl(url, node, options?.absolute, options?.context);
+ }
+ return undefined;
+}
+
/** The widths Jahia can resize to offline, exposed for docs and tests. */
export const THUMBNAIL_WIDTHS: readonly number[] = THUMBNAILS.map(({ width }) => width);
diff --git a/javascript-modules-library/src/utils/image/devWarnings.ts b/javascript-modules-library/src/utils/image/devWarnings.ts
new file mode 100644
index 00000000..309a4fae
--- /dev/null
+++ b/javascript-modules-library/src/utils/image/devWarnings.ts
@@ -0,0 +1,120 @@
+import type { JCRNodeWrapper } from "org.jahia.services.content";
+
+/**
+ * True on a development instance.
+ *
+ * Every part of this call can be missing — the whole `server` bridge outside the engine, the method
+ * on an engine older than it — and a diagnostic that cannot tell must stay quiet rather than fail.
+ */
+const isDevelopmentMode = (): boolean => {
+ try {
+ return server.config.isDevelopmentMode();
+ } catch {
+ return false;
+ }
+};
+
+/**
+ * What has already been said. A diagnostic describes a mistake in the code, not an event, so the
+ * second occurrence of the same key carries no information the first did not.
+ */
+const reported = new Set();
+
+/**
+ * Prints one line per key, on a development instance only — a production instance pays nothing.
+ *
+ * @param key - What makes two occurrences the same mistake.
+ * @param message - Built only when it will actually be printed.
+ */
+const warnOnce = (key: string, message: () => string): void => {
+ if (!isDevelopmentMode() || reported.has(key)) return;
+
+ reported.add(key);
+ console.warn(message());
+};
+
+/** How a message names the image it is about. */
+const identify = (node: JCRNodeWrapper): string => {
+ try {
+ return node.getPath() || "an image";
+ } catch {
+ return "an image";
+ }
+};
+
+/** Where the messages send the reader for the long version. */
+const GUIDE = "docs/2-guides/8-images/README.md";
+
+/**
+ * Warns, once, that the widths of an image travel as `?w=` parameters this instance most likely
+ * ignores.
+ *
+ * Nothing else reports that trap: the markup is correct, only the bytes never shrink. What the
+ * warning reports is a property of the instance, not of the image — if these parameters are ignored
+ * for one asset they are ignored for every one — so one line per engine lifetime says everything a
+ * second would.
+ *
+ * @param node - The file node whose candidates landed on the `query` channel.
+ * @see {@link ImageResizeChannel} for what each channel does with a requested size.
+ */
+export function warnIgnoredResize(node: JCRNodeWrapper): void {
+ warnOnce(
+ "ignored-resize",
+ () =>
+ `getImageProps: the ?w= candidates of ${identify(node)} — and of every other image on this ` +
+ `instance — return the original bytes, because only Media Optimization reads those ` +
+ `parameters. Expected on an instance without it; the markup is still correct. ` +
+ `See ${GUIDE}, "What actually resizes the image, and where".`,
+ );
+}
+
+/**
+ * Warns, once per asset, that Jahia never extracted the pixel size of a raster image.
+ *
+ * Everything the library derives from the intrinsic size is lost silently: candidates are no longer
+ * capped by the original, no `width`/`height` pair reserves the space, and without a reserved space
+ * the image cannot be lazy-loaded either. The markup that comes out is valid, which is exactly why
+ * nobody notices.
+ *
+ * @param node - The raster file node carrying no `j:width`.
+ */
+export function warnMissingIntrinsicSize(node: JCRNodeWrapper): void {
+ const path = identify(node);
+ warnOnce(
+ `missing-intrinsic-size:${path}`,
+ () =>
+ `getImageProps: ${path} carries no j:width, so Jahia never extracted its pixel size. ` +
+ `Candidates are not capped by the original, no width/height pair reserves its space, and ` +
+ `without that reservation the image is not lazy-loaded. Re-upload the file, or run the ` +
+ `image extractor over it. See ${GUIDE}, "When Jahia never measured the image".`,
+ );
+}
+
+/**
+ * Warns, once per asset, that `sizes="auto"` and an eager load cannot both be honoured.
+ *
+ * These two legitimately arrive from different layers — a shared wrapper defaults every image to
+ * `sizes="auto"`, a leaf view marks this one as the page's LCP element — and neither layer can see
+ * the other. Refusing to render would turn a wrapper's default into a 500 on a production page, so
+ * the eager load wins and the derived `sizes` replaces `auto`.
+ *
+ * @param node - The image whose `sizes` was replaced.
+ * @param eagerness - Which prop asked for the eager load, quoted as the caller wrote it.
+ * @param sizes - The value used instead.
+ */
+export function warnAutoSizesEager(
+ node: JCRNodeWrapper,
+ eagerness: string,
+ sizes: string | undefined,
+): void {
+ const path = identify(node);
+ warnOnce(
+ `auto-sizes-eager:${path}`,
+ () =>
+ `JImage: ${path} asks for both sizes="auto" and ${eagerness}, and browsers only read "auto" ` +
+ `on a lazily loaded image. The eager load wins, and ` +
+ `${sizes ? `sizes="${sizes}"` : "no sizes"} is used instead. Describe the slot yourself to ` +
+ `choose a better one, as in sizes="(min-width: 60rem) 33vw, 100vw". ` +
+ `See ${GUIDE}, "Above the fold: preload".`,
+ );
+}
diff --git a/javascript-modules-library/src/utils/image/getImageProps.ts b/javascript-modules-library/src/utils/image/getImageProps.ts
index 4149d48b..c5b6a687 100644
--- a/javascript-modules-library/src/utils/image/getImageProps.ts
+++ b/javascript-modules-library/src/utils/image/getImageProps.ts
@@ -1,29 +1,80 @@
import type { JCRNodeWrapper } from "org.jahia.services.content";
-import type { RenderContext, Resource } from "org.jahia.services.render";
-import { buildImageUrl, type ImageResizeChannel } from "./buildImageUrl.js";
+import { buildModuleFileUrl } from "../urlBuilder/urlBuilder.js";
+import {
+ buildImageUrl,
+ commaSafe,
+ type ImageResizeChannel,
+ type ImageUrlOptions,
+} from "./buildImageUrl.js";
+import { warnIgnoredResize, warnMissingIntrinsicSize } from "./devWarnings.js";
+import {
+ resolveImageDefaults,
+ type ImageContext,
+ type ImageSourceOptions,
+} from "./imageDefaults.js";
import { clampToIntrinsic, readImageMeta } from "./imageMeta.js";
-import { warnIgnoredResize } from "./warnIgnoredResize.js";
+import { ladderFromSizes } from "./sizesLadder.js";
/**
* How the image occupies its slot. Declaring the intent lets the library derive both `srcSet` and
* `sizes`, which is otherwise the part of responsive images that every call site gets wrong.
*
- * - `constrained` (default): the image is at most `width` CSS pixels wide and shrinks with the
- * viewport below that — the common case for content in a column.
- * - `fixed`: the image is always `width` CSS pixels wide (an avatar, a logo slot, a card thumbnail in
- * a fixed grid).
- * - `full-width`: the image always spans the viewport (a hero).
+ * The first question is not how wide the slot is, it is whether anything in the markup _knows_ how
+ * wide it is — and, when nothing does, whether the image sits in the normal flow or is stretched
+ * over a parent.
+ *
+ * - `constrained` (default): an image in the normal flow. It is described _either_ by a `slotWidth`,
+ * when the slot is at most that many CSS pixels and shrinks with the viewport below it, _or_ by a
+ * `sizes`, when CSS the markup cannot read decides its width — a `%`, a `fr`, a grid cell, an
+ * `aspect-ratio` box.
+ * - `fixed`: the image is always `slotWidth` CSS pixels wide (an avatar, a logo slot, a card
+ * thumbnail in a fixed grid). It takes no `sizes`: the slot is one number.
+ * - `full-width`: the image always spans the viewport (a hero, a full-bleed banner).
+ * - `fill`: the image is positioned _over_ its closest positioned ancestor, which owns the box. It
+ * needs a `sizes`, and unlike every other layout it carries CSS of its own and drops the
+ * intrinsic dimensions, which would fight that parent.
*/
-export type ImageLayout = "constrained" | "fixed" | "full-width";
+export type ImageLayout = "constrained" | "fixed" | "full-width" | "fill";
+
+/**
+ * How the slot is described: one way per layout, never two.
+ *
+ * A `slotWidth` and a `sizes` are two descriptions of the same box, and nothing can reconcile them
+ * — so `constrained` takes exactly one of them, and the ladder of candidate files follows whichever
+ * one was written. Passing both used to be accepted, and the `sizes` was emitted while the
+ * candidates were still derived from the `slotWidth`: two claims about one slot, disagreeing
+ * silently.
+ *
+ * TypeScript rejects the combinations below; {@link getImageProps} throws on them too, for the
+ * untyped JavaScript caller and for the `{...props}` spread that defeats a union.
+ */
+export type ImageSlot =
+ /** At most `slotWidth` CSS pixels, shrinking with the viewport below that. */
+ | { layout?: "constrained"; slotWidth: number; sizes?: never }
+ /**
+ * Sized by CSS the markup cannot read, so the `sizes` string is the only description of the slot
+ * — and the one the candidate ladder is derived from. `"auto"` lets the browser measure the real
+ * box.
+ */
+ | { layout?: "constrained"; slotWidth?: never; sizes: string }
+ /** Always `slotWidth` CSS pixels. No `sizes`: the slot is one number. */
+ | { layout: "fixed"; slotWidth: number; sizes?: never }
+ /** The viewport. `sizes` defaults to `100vw`; write one only to say `"auto"`. */
+ | { layout: "full-width"; slotWidth?: never; sizes?: string }
+ /** A box owned by a positioned parent, which only `sizes` can describe. */
+ | { layout: "fill"; slotWidth?: never; sizes: string };
/**
* Candidate file widths, in image pixels, offered for the layouts where the slot is not a single
- * number — `constrained` below its maximum, and `full-width` always. A `fixed` slot never uses
- * them: its width and that width doubled cover it.
+ * number — `constrained` below its maximum, `full-width` and `fill` always. A `fixed` slot never
+ * uses them: its width and that width doubled cover it.
*
* These are widths of _files_, not breakpoints of the layout: the slot is described by `sizes`, and
* the browser matches one against the other at load time. Doubling-ish steps keep the ladder short,
* because a candidate only pays for itself if it is meaningfully smaller than the next one up.
+ *
+ * The two ends also stand in for the viewport range a `sizes`-described slot is planned against;
+ * see {@link ladderFromSizes}.
*/
export const DEFAULT_BREAKPOINTS: readonly number[] = [320, 640, 960, 1280, 1920, 2560];
@@ -33,7 +84,7 @@ export const DEFAULT_BREAKPOINTS: readonly number[] = [320, 640, 960, 1280, 1920
* Plain, serializable data on purpose: this is also the shape to pass through `` props,
* where a React element cannot travel.
*/
-export interface ImageProps {
+export interface ImgProps {
src: string;
srcSet?: string;
sizes?: string;
@@ -41,6 +92,11 @@ export interface ImageProps {
width?: number;
/** Intrinsic height in image pixels, when Jahia extracted it. */
height?: number;
+ /**
+ * Set to `"lazy"` when `sizes` resolves to `auto`, which browsers only honour on a lazily loaded
+ * image. Render it — dropping it turns `auto` into `100vw` and downloads the largest candidate.
+ */
+ loading?: "lazy" | "eager";
/**
* Alternative text. Required — an image that carries no information for a screen reader is
* declared with `alt=""`, explicitly.
@@ -48,22 +104,13 @@ export interface ImageProps {
alt: string;
}
-export interface ImageOptions {
+/** What {@link getImageProps} takes, apart from the slot description in {@link ImageSlot}. */
+export interface ImageOptionsBase extends ImageSourceOptions {
/** Alternative text; `""` declares the image decorative. */
alt: string;
- /**
- * How the image occupies its slot.
- *
- * @default "constrained"
- */
- layout?: ImageLayout;
- /** The slot width in CSS pixels. Required by `constrained` and `fixed`. */
- width?: number;
- /** Explicit candidate widths in image pixels. Escape hatch: prefer `layout` + `width`. */
+ /** Explicit candidate widths in image pixels. Overrides the ladder the layout would derive. */
widths?: number[];
- /** Explicit `sizes` attribute. Escape hatch: prefer `layout` + `width`. */
- sizes?: string;
- /** Candidate ladder used by `constrained` and `full-width`. */
+ /** Candidate ladder used by every layout but `fixed`. */
breakpoints?: readonly number[];
/**
* Register a render cache dependency on the image node, so that editing the image flushes the
@@ -72,104 +119,232 @@ export interface ImageOptions {
* @default true
*/
cacheDependency?: boolean;
+ /**
+ * A module static asset (`import placeholder from "/static/img/placeholder.jpg"`) used when there
+ * is no `node`, so an unfilled content property does not leave a broken image. Without one, a
+ * missing node returns `null`.
+ */
+ fallback?: string;
}
/**
- * Commas are legal inside a URL but ambiguous with the `srcSet` candidate separator, and Jahia's
- * srcset rewriter splits on every comma — corrupting, for instance, a Cloudinary transformation URL
- * (`…/upload/f_auto,w_600/…`). Percent-encoding them inside `srcSet` only keeps both readers
- * happy.
+ * What {@link getImageProps} takes: the alternative text, the slot description, and the overrides.
*
- * @see {@link https://github.com/Jahia/jahia/issues/23}
+ * `slotWidth` is named apart from the `width` HTML attribute on purpose: it is how much room the
+ * layout gives the image, not a number that ends up in the markup.
*/
-const srcSetSafe = (url: string) => url.replaceAll(",", "%2C");
+export type ImageOptions = ImageOptionsBase & ImageSlot;
+
+/**
+ * True for a `sizes` value whose first entry is `auto`.
+ *
+ * The spec allows a fallback after it (`"auto, 50vw"`) for browsers that do not implement it, so
+ * the marker is the first entry rather than the whole string.
+ *
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#sizes}
+ */
+export const isAutoSizes = (sizes: string | undefined): boolean =>
+ sizes !== undefined && sizes.trim().split(",")[0].trim().toLowerCase() === "auto";
+
+/** A slot description the layout accepts, with the `sizes` it resolves to. */
+interface ResolvedSlot {
+ /** The `sizes` attribute this slot implies, derived when the caller wrote none. */
+ sizes: string;
+ /** The slot width in CSS pixels, when the caller described the slot with one. */
+ slotWidth?: number;
+}
+
+/**
+ * Checks that the slot is described exactly once, the way {@link ImageSlot} says, and resolves the
+ * `sizes` that description implies.
+ *
+ * {@link ImageSlot} already rejects each of these at the call site. The throws are for the untyped
+ * JavaScript caller and for the `{...props}` spread, which defeats a union — and they name the
+ * exits rather than only the rule, because the caller reading them has just been told their view is
+ * wrong and needs to know what to write instead.
+ */
+const resolveSlot = (
+ layout: ImageLayout,
+ slotWidth: number | undefined,
+ sizes: string | undefined,
+): ResolvedSlot => {
+ const takesNoSlotWidth = () => {
+ if (slotWidth !== undefined) {
+ throw new Error(
+ `getImageProps: layout "${layout}" takes no slotWidth, because the slot is not a number of ` +
+ "CSS pixels the markup states. Describe it with sizes instead.",
+ );
+ }
+ };
+
+ switch (layout) {
+ case "full-width":
+ takesNoSlotWidth();
+ return { sizes: sizes ?? "100vw" };
-/** The candidate widths a layout asks for, before clamping. */
+ case "fill":
+ takesNoSlotWidth();
+ if (sizes === undefined) throw new Error(needsSizes(layout));
+ return { sizes };
+
+ case "fixed":
+ if (sizes !== undefined) {
+ throw new Error(
+ 'getImageProps: layout "fixed" takes no sizes, because the layout means the slot is one ' +
+ 'number and slotWidth already states it. Use layout "constrained" with a sizes for a ' +
+ "slot whose width changes.",
+ );
+ }
+ if (slotWidth === undefined) {
+ throw new Error(
+ 'getImageProps: layout "fixed" needs a slotWidth (the slot width in CSS pixels).',
+ );
+ }
+ return { sizes: `${slotWidth}px`, slotWidth };
+
+ case "constrained":
+ if (slotWidth !== undefined && sizes !== undefined) {
+ throw new Error(
+ 'getImageProps: layout "constrained" takes a slotWidth or a sizes, never both. They are ' +
+ "two descriptions of one slot, and the candidate files can only follow one of them. " +
+ "Keep slotWidth when the slot is at most that many CSS pixels; keep sizes when CSS the " +
+ "markup cannot read decides its width.",
+ );
+ }
+ if (sizes !== undefined) return { sizes };
+ if (slotWidth === undefined) throw new Error(needsSizes(layout));
+ return { sizes: `(min-width: ${slotWidth}px) ${slotWidth}px, 100vw`, slotWidth };
+ }
+};
+
+/** Names both exits, because a caller who wrote neither has to be told there are two. */
+const needsSizes = (layout: ImageLayout): string =>
+ `getImageProps: layout "${layout}" needs the slot described, and nothing in the markup ` +
+ "describes it. Either give a slotWidth, the slot width in CSS pixels, or give a sizes — " +
+ 'sizes="auto" lets the browser measure the real box, and sizes="(min-width: 60rem) 33vw, 100vw" ' +
+ "describes it yourself.";
+
+/** The candidate widths a slot asks for, before clamping. */
const candidateWidths = (
layout: ImageLayout,
- width: number | undefined,
+ slot: ResolvedSlot,
breakpoints: readonly number[],
): number[] => {
- if (layout === "full-width") return [...breakpoints];
-
- if (width === undefined) {
- throw new Error(
- `getImageProps: layout "${layout}" needs a width (the slot width in CSS pixels). ` +
- `Use layout "full-width" for an image that always spans the viewport.`,
- );
- }
+ // No number in the markup: the `sizes` string is the only description of the slot, so the ladder
+ // is derived from it rather than from a width that string never mentions
+ if (slot.slotWidth === undefined) return ladderFromSizes(slot.sizes, breakpoints);
+ const width = slot.slotWidth;
// Two device-pixel ratios cover the realistic range; a 3x file is rarely worth its bytes
const densities = [width, width * 2];
if (layout === "fixed") return densities;
- // Constrained: the slot shrinks with the viewport, so smaller files are useful too
- return [...breakpoints.filter((candidate) => candidate < width), ...densities];
-};
-
-/** The `sizes` attribute a layout implies. */
-const derivedSizes = (layout: ImageLayout, width: number | undefined): string => {
- switch (layout) {
- case "full-width":
- return "100vw";
- case "fixed":
- return `${width}px`;
- case "constrained":
- return `(min-width: ${width}px) ${width}px, 100vw`;
- }
+ // Constrained: the slot shrinks with the viewport, so every file up to the 2x one is useful — a
+ // 1.33x screen at the full slot should get the band above the slot, not the top of the ladder
+ return [...breakpoints.filter((candidate) => candidate <= 2 * width), ...densities];
};
/**
* Builds `
` props from a Jahia image node: a resized `src`, a `srcSet` of candidates, the
* matching `sizes`, and the intrinsic dimensions.
*
- * Declare how the image sits in the page with `layout` + `width` and the candidates and `sizes` are
- * derived; `widths` and `sizes` remain available for the cases that need exact control.
+ * Describe the slot once — a `slotWidth` when the markup states its width in CSS pixels, a `sizes`
+ * when only CSS knows it — and both the candidates and the `sizes` attribute follow that one
+ * description. On a fluid design most slots are the second kind, and `sizes="auto"` lets the
+ * browser measure the real box.
*
* @example
* ```tsx
- *
+ * const context = useServerContext();
+ *
+ *
* ```;
*
- * @param node - The file node holding the image.
+ * @param node - The file node holding the image. When missing, `options.fallback` is used instead.
* @param options - Alternative text (required) and how the image is laid out.
- * @param context - Provided by React context on the server; pass one when calling outside a render.
- * @returns Plain, serializable `
` props — safe to pass through `` props.
+ * @param context - The render context, from `useServerContext()`. It carries the cache dependency
+ * and selects the module whose {@link setImageDefaults} apply, so a call without it silently loses
+ * both.
+ * @returns Plain, serializable `
` props — safe to pass through `` props — or `null`
+ * when there is neither a node nor a fallback.
* @see {@link JImage} for the component that renders these props.
*/
export function getImageProps(
node: JCRNodeWrapper,
options: ImageOptions,
- context?: { renderContext?: RenderContext; currentResource?: Resource },
-): ImageProps {
+ context: ImageContext,
+): ImgProps;
+export function getImageProps(
+ node: JCRNodeWrapper | null | undefined,
+ options: ImageOptions & { fallback: string },
+ context: ImageContext,
+): ImgProps;
+export function getImageProps(
+ node: JCRNodeWrapper | null | undefined,
+ options: ImageOptions,
+ context: ImageContext,
+): ImgProps | null;
+export function getImageProps(
+ node: JCRNodeWrapper | null | undefined,
+ options: ImageOptions,
+ context: ImageContext,
+): ImgProps | null {
const {
alt,
layout = "constrained",
- width,
+ slotWidth,
widths,
sizes,
- breakpoints = DEFAULT_BREAKPOINTS,
cacheDependency = true,
+ fallback,
} = options;
+ // Before the node is even looked at, so that a view describing its slot wrongly fails the same
+ // way whether or not the content property happens to be filled
+ const slot = resolveSlot(layout, slotWidth, sizes);
+
+ if (!node) {
+ return fallback ? { src: buildModuleFileUrl(fallback, {}, context), alt: alt.trim() } : null;
+ }
+
const meta = readImageMeta(node);
- const renderContext = context?.renderContext;
- if (cacheDependency && renderContext) {
- server.render.addCacheDependency({ node }, renderContext);
+ const defaults = resolveImageDefaults(options, context);
+ const breakpoints = options.breakpoints ?? defaults.breakpoints ?? DEFAULT_BREAKPOINTS;
+
+ // One dependency for the whole set, rather than one per candidate URL
+ const urlOptions: ImageUrlOptions = {
+ ...options,
+ meta,
+ context,
+ cacheDependency: false,
+ };
+ if (cacheDependency && context.renderContext) {
+ server.render.addCacheDependency({ node }, context.renderContext);
}
const base = {
alt: alt.trim(),
- width: meta.intrinsicWidth,
- height: meta.intrinsicHeight,
+ // `fill` takes its box from its parent: intrinsic attributes would fight that CSS
+ width: layout === "fill" ? undefined : meta.intrinsicWidth,
+ height: layout === "fill" ? undefined : meta.intrinsicHeight,
};
- // A vector needs no candidates: one resolution-independent file serves every slot
- if (meta.vector) {
- return { ...base, src: buildImageUrl(node, undefined, { meta, context }).url };
+ const withLoading = (props: ImgProps): ImgProps =>
+ isAutoSizes(props.sizes) ? { ...props, loading: "lazy" } : props;
+
+ // A vector needs no candidates: one resolution-independent file serves every slot. Neither does
+ // an image the caller opted out of resizing.
+ if (meta.vector || defaults.unoptimized) {
+ return withLoading({
+ ...base,
+ src: buildImageUrl(node, undefined, urlOptions).url,
+ sizes,
+ });
}
- const requested = (widths ?? candidateWidths(layout, width, breakpoints))
+ if (meta.intrinsicWidth === undefined) warnMissingIntrinsicSize(node);
+
+ const requested = (widths ?? candidateWidths(layout, slot, breakpoints))
.filter((candidate) => candidate > 0)
.map((candidate) => clampToIntrinsic(candidate, meta.intrinsicWidth))
.sort((a, b) => a - b);
@@ -187,7 +362,7 @@ export function getImageProps(
const widthByUrl = new Map();
let ignoredResize = false;
for (const candidate of requested) {
- const { url, channel } = buildImageUrl(node, { width: candidate }, { meta, context });
+ const { url, channel } = buildImageUrl(node, { width: candidate }, urlOptions);
if (channel === "query") ignoredResize = true;
if (!widthByUrl.has(url)) widthByUrl.set(url, candidate);
}
@@ -195,15 +370,16 @@ export function getImageProps(
if (ignoredResize) warnIgnoredResize(node);
const [smallest] = [...widthByUrl.keys()];
- return {
+ return withLoading({
...base,
- src: smallest ?? buildImageUrl(node, undefined, { meta, context }).url,
+ src: smallest ?? buildImageUrl(node, undefined, urlOptions).url,
srcSet:
widthByUrl.size > 1
- ? [...widthByUrl].map(([url, candidate]) => `${srcSetSafe(url)} ${candidate}w`).join(", ")
+ ? [...widthByUrl].map(([url, candidate]) => `${commaSafe(url)} ${candidate}w`).join(", ")
: undefined,
- sizes: widthByUrl.size > 1 ? (sizes ?? derivedSizes(layout, width)) : sizes,
- };
+ // Below two candidates there is no choice to describe, so only an explicit `sizes` survives
+ sizes: widthByUrl.size > 1 ? slot.sizes : sizes,
+ });
}
/**
@@ -212,11 +388,19 @@ export function getImageProps(
* meaning the URLs carry a size hint that only Media Optimization (on Jahia Cloud, in live mode)
* interprets.
*
+ * Pass the same options the real call uses: a module-wide loader, or `unoptimized`, changes the
+ * answer, and an inspection that ignored them would report a channel the images never take.
+ *
* @param node - The file node holding the image.
* @param width - The width to inspect.
+ * @param options - The loader, quality and context the real call would use.
* @returns The channel that would carry that width.
* @see {@link ImageResizeChannel}
*/
-export function inspectImageChannel(node: JCRNodeWrapper, width: number): ImageResizeChannel {
- return buildImageUrl(node, { width }).channel;
+export function inspectImageChannel(
+ node: JCRNodeWrapper,
+ width: number,
+ options?: ImageUrlOptions,
+): ImageResizeChannel {
+ return buildImageUrl(node, { width }, { ...options, cacheDependency: false }).channel;
}
diff --git a/javascript-modules-library/src/utils/image/image.spec.ts b/javascript-modules-library/src/utils/image/image.spec.ts
index 4ec088d6..8d20b72e 100644
--- a/javascript-modules-library/src/utils/image/image.spec.ts
+++ b/javascript-modules-library/src/utils/image/image.spec.ts
@@ -1,26 +1,56 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { JCRNodeWrapper } from "org.jahia.services.content";
+import type { ImageOptions, ImgProps } from "./getImageProps.js";
+import type { ImageContext } from "./imageDefaults.js";
// `buildNodeUrl` reaches into the Jahia render context, which only exists inside the engine. The
// mock reproduces the two channels it offers: `parameters` become a query string (the default
// provider, honoured by Media Optimization), and `args` go through node.getUrl(["w:600"]), which a
// DAM decorator turns into a signed, transformed URL.
-vi.mock("../urlBuilder/urlBuilder.js", () => ({
- buildNodeUrl: (
- node: { url: string; getUrl: (params: string[]) => string },
- config?: { parameters?: Record; args?: Record },
- ) => {
- if (config?.args) {
- return node.getUrl(Object.entries(config.args).map(([key, value]) => `${key}:${value}`));
- }
+vi.mock("../urlBuilder/urlBuilder.js", async () => {
+ // `toAbsoluteUrl` lives in its own module and stays real: prefixing an origin is behaviour under
+ // test here, not a dependency to fake.
+ const { toAbsoluteUrl } = await import("../urlBuilder/absoluteUrl.js");
- return config?.parameters ? `${node.url}?${new URLSearchParams(config.parameters)}` : node.url;
- },
- buildModuleFileUrl: (path: string) => `/modules/test${path}`,
-}));
+ return {
+ buildNodeUrl: (
+ node: { url: string; getUrl: (params: string[]) => string },
+ config?: {
+ parameters?: Record;
+ args?: Record;
+ absolute?: boolean | string;
+ },
+ ) => {
+ const url = config?.args
+ ? node.getUrl(Object.entries(config.args).map(([key, value]) => `${key}:${value}`))
+ : config?.parameters
+ ? `${node.url}?${new URLSearchParams(config.parameters)}`
+ : node.url;
-const { buildImageUrl } = await import("./buildImageUrl.js");
-const { getImageProps, DEFAULT_BREAKPOINTS } = await import("./getImageProps.js");
+ return toAbsoluteUrl(url, node as never, config?.absolute);
+ },
+ buildModuleFileUrl: (path: string) => `/modules/test/${path}`,
+ };
+});
+
+const { buildImageUrl, buildBackgroundImageUrl, buildThumbnailUrl } =
+ await import("./buildImageUrl.js");
+const { setImageDefaults, clearImageDefaults } = await import("./imageDefaults.js");
+const {
+ getImageProps: getImagePropsWithContext,
+ inspectImageChannel,
+ DEFAULT_BREAKPOINTS,
+} = await import("./getImageProps.js");
+
+/**
+ * The render context is required, because a call without one silently loses both the cache
+ * dependency and the module's defaults. The tests that are not about it pass an empty one.
+ */
+const getImageProps = (
+ node: JCRNodeWrapper,
+ options: ImageOptions,
+ context: ImageContext = {},
+): ImgProps => getImagePropsWithContext(node, options, context);
const { readImageMeta } = await import("./imageMeta.js");
/** A JCR file node holding an image, with just the surface the image code touches. */
@@ -32,6 +62,7 @@ const imageNode = ({
height,
defaultProvider = true,
thumbnails = ["thumbnail", "thumbnail2"],
+ serverName = "www.example.com",
getUrl,
}: {
url?: string;
@@ -41,6 +72,7 @@ const imageNode = ({
height?: number;
defaultProvider?: boolean;
thumbnails?: string[];
+ serverName?: string;
getUrl?: (params: string[]) => string;
} = {}) =>
({
@@ -51,6 +83,7 @@ const imageNode = ({
return path;
},
getProvider: () => ({ isDefault: () => defaultProvider, getKey: () => "test" }),
+ getResolveSite: () => ({ getServerName: () => serverName }),
// A DAM decorator signs the transformed URL; the default provider discards these params
getUrl: getUrl ?? ((params: string[]) => `${url}#signed(${params.join(",")})`),
getThumbnailUrl: (name: string) => {
@@ -153,9 +186,10 @@ describe("buildImageUrl", () => {
});
describe("getImageProps", () => {
- it("requires a width for a constrained layout, and says why", () => {
+ it("refuses a constrained slot described neither way, and names both exits", () => {
+ // @ts-expect-error a constrained slot needs a slotWidth or a sizes
expect(() => getImageProps(imageNode({ width: 2000 }), { alt: "" })).toThrow(
- /layout "constrained" needs a width/,
+ /needs the slot described[\s\S]*slotWidth[\s\S]*sizes/,
);
});
@@ -163,7 +197,7 @@ describe("getImageProps", () => {
const props = getImageProps(imageNode({ width: 2000, height: 1000 }), {
alt: "A terrace",
layout: "fixed",
- width: 300,
+ slotWidth: 300,
});
// 300 and its 2x variant, so a retina screen gets a sharp file
expect(props.srcSet).toBe("/files/photo.jpg?w=300 300w, /files/photo.jpg?w=600 600w");
@@ -174,11 +208,14 @@ describe("getImageProps", () => {
it("adds smaller candidates for a constrained slot that can shrink", () => {
const props = getImageProps(imageNode({ width: 4000 }), {
alt: "A terrace",
- width: 960,
+ slotWidth: 960,
});
+ // Every band up to twice the slot, 1280 included: a 1.33x screen at the full slot gets that
+ // file rather than climbing to the 1920 one
expect(props.srcSet).toBe(
"/files/photo.jpg?w=320 320w, /files/photo.jpg?w=640 640w, " +
- "/files/photo.jpg?w=960 960w, /files/photo.jpg?w=1920 1920w",
+ "/files/photo.jpg?w=960 960w, /files/photo.jpg?w=1280 1280w, " +
+ "/files/photo.jpg?w=1920 1920w",
);
expect(props.sizes).toBe("(min-width: 960px) 960px, 100vw");
});
@@ -197,11 +234,13 @@ describe("getImageProps", () => {
it("keeps the original as a candidate only when it is close to the largest requested", () => {
// 2000 <= 2 x 1280: a useful top candidate
expect(
- getImageProps(imageNode({ width: 2000 }), { alt: "", layout: "fixed", width: 640 }).srcSet,
+ getImageProps(imageNode({ width: 2000 }), { alt: "", layout: "fixed", slotWidth: 640 })
+ .srcSet,
).toContain("/files/photo.jpg 2000w");
// 8000 > 2 x 1280: serving the master into that slot would waste megabytes
expect(
- getImageProps(imageNode({ width: 8000 }), { alt: "", layout: "fixed", width: 640 }).srcSet,
+ getImageProps(imageNode({ width: 8000 }), { alt: "", layout: "fixed", slotWidth: 640 })
+ .srcSet,
).not.toContain("8000w");
});
@@ -217,7 +256,7 @@ describe("getImageProps", () => {
return `https://dam.example/a.jpg#rendition(${rendition})`;
},
});
- const props = getImageProps(node, { alt: "", layout: "fixed", width: 600 });
+ const props = getImageProps(node, { alt: "", layout: "fixed", slotWidth: 600 });
// 600 and 1200 both snap to the 1024 rendition: under-claim it as 600w so the browser climbs
// to the next candidate instead of painting an upscaled image
expect(props.srcSet).toBe(
@@ -237,7 +276,7 @@ describe("getImageProps", () => {
getUrl: (params) =>
`https://cdn.example/image/upload/f_auto,${params[0].replace(":", "_")}/v1/a.jpg`,
});
- const props = getImageProps(node, { alt: "", layout: "fixed", width: 600 });
+ const props = getImageProps(node, { alt: "", layout: "fixed", slotWidth: 600 });
expect(props.srcSet).not.toMatch(/,\S/);
expect(props.srcSet).toContain("f_auto%2Cw_600");
// A single URL is unambiguous, so `src` keeps its real commas
@@ -248,7 +287,7 @@ describe("getImageProps", () => {
const props = getImageProps(imageNode({ url: "/files/logo.svg", mimeType: "image/svg+xml" }), {
alt: "Acme",
layout: "fixed",
- width: 100,
+ slotWidth: 100,
});
expect(props).toEqual({
src: "/files/logo.svg",
@@ -262,13 +301,27 @@ describe("getImageProps", () => {
const props = getImageProps(imageNode({ width: 200 }), {
alt: "",
layout: "fixed",
- width: 300,
+ slotWidth: 300,
});
// Every candidate clamps to 200, which is a no-op resize: one original URL, no srcSet
expect(props.src).toBe("/files/photo.jpg");
expect(props.srcSet).toBeUndefined();
});
+ it("refuses explicit widths with no slot to describe, rather than emitting undefinedpx", () => {
+ // The widths bypass the ladder, so nothing else asks for the slot — and the `sizes` derived
+ // from a missing one used to read "(min-width: undefinedpx) undefinedpx, 100vw", which
+ // browsers discard before fetching the largest candidate on every screen
+ expect(() =>
+ // @ts-expect-error a constrained slot needs a slotWidth or a sizes
+ getImageProps(imageNode({ width: 4000 }), { alt: "", widths: [400, 800] }),
+ ).toThrow(/needs the slot described/);
+ expect(() =>
+ // @ts-expect-error a fixed slot needs a slotWidth
+ getImageProps(imageNode({ width: 4000 }), { alt: "", layout: "fixed", widths: [400, 800] }),
+ ).toThrow(/layout "fixed" needs a slotWidth/);
+ });
+
it("takes explicit widths and sizes as an escape hatch", () => {
const props = getImageProps(imageNode({ width: 2000 }), {
alt: "",
@@ -281,14 +334,14 @@ describe("getImageProps", () => {
it("trims the alt text and keeps an explicit empty one", () => {
const node = imageNode({ width: 2000 });
- expect(getImageProps(node, { alt: " A terrace ", layout: "fixed", width: 300 }).alt).toBe(
+ expect(getImageProps(node, { alt: " A terrace ", layout: "fixed", slotWidth: 300 }).alt).toBe(
"A terrace",
);
- expect(getImageProps(node, { alt: "", layout: "fixed", width: 300 }).alt).toBe("");
+ expect(getImageProps(node, { alt: "", layout: "fixed", slotWidth: 300 }).alt).toBe("");
});
});
-describe("the ignored-resize warning", () => {
+describe("the development warnings", () => {
/** The engine injects `server` as a global; a test provides only the part under test. */
const stubDevelopmentMode = (developmentMode: boolean) => {
Reflect.set(globalThis, "server", {
@@ -300,14 +353,17 @@ describe("the ignored-resize warning", () => {
* The warning latches a module-scope flag — once per engine lifetime is the point of it — so each
* test needs its own copy of the module rather than the one a previous test already silenced.
*/
- let freshImageProps: typeof getImageProps;
+ let reimported: typeof getImagePropsWithContext;
+ const freshImageProps = (node: JCRNodeWrapper, options: ImageOptions) =>
+ reimported(node, options, {});
+
beforeEach(async () => {
vi.resetModules();
- ({ getImageProps: freshImageProps } = await import("./getImageProps.js"));
+ ({ getImageProps: reimported } = await import("./getImageProps.js"));
});
/** A slot of 600 on a 2000px original: candidates no thumbnail covers, so `?w=` carries them. */
- const slot = { alt: "", layout: "fixed", width: 600 } as const;
+ const slot = { alt: "", layout: "fixed", slotWidth: 600 } as const;
afterEach(() => {
vi.restoreAllMocks();
@@ -350,6 +406,8 @@ describe("the ignored-resize warning", () => {
// A pre-generated thumbnail width: a real resize on any instance
freshImageProps(imageNode({ path: "/sites/test/files/thumb.jpg", width: 2000 }), {
alt: "",
+ layout: "fixed",
+ slotWidth: 150,
widths: [150],
});
// An external provider, whose decorator signs a transformed URL
@@ -389,4 +447,603 @@ describe("the ignored-resize warning", () => {
expect(() => freshImageProps(imageNode({ width: 2000 }), slot)).not.toThrow();
expect(warn).toHaveBeenCalledTimes(1);
});
+
+ /** Every message printed, as strings, since several warnings can be about the same render. */
+ const messagesOf = (warn: { mock: { calls: unknown[][] } }): string[] =>
+ warn.mock.calls.map(([message]) => String(message));
+
+ it("names j:width when Jahia never measured a raster image", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ stubDevelopmentMode(true);
+
+ // No `j:width`: candidates are no longer capped by the original, nothing reserves the space,
+ // and without that reservation the image is not lazy-loaded either
+ freshImageProps(imageNode({ path: "/sites/test/files/unmeasured.jpg" }), slot);
+
+ const missingSize = messagesOf(warn).filter((message) => message.includes("j:width"));
+ expect(missingSize).toHaveLength(1);
+ expect(missingSize[0]).toContain("/sites/test/files/unmeasured.jpg");
+ });
+
+ it("says nothing about j:width for an image Jahia did measure", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ stubDevelopmentMode(true);
+
+ freshImageProps(imageNode({ path: "/sites/test/files/measured.jpg", width: 2000 }), slot);
+
+ expect(messagesOf(warn).some((message) => message.includes("j:width"))).toBe(false);
+ });
+
+ it("warns once per unmeasured image, and once more for the next one", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ stubDevelopmentMode(true);
+
+ const node = imageNode({ path: "/sites/test/files/one.jpg" });
+ freshImageProps(node, slot);
+ freshImageProps(node, slot);
+ freshImageProps(imageNode({ path: "/sites/test/files/two.jpg" }), slot);
+
+ expect(messagesOf(warn).filter((message) => message.includes("j:width"))).toHaveLength(2);
+ });
+});
+
+describe("a slot described by its sizes", () => {
+ it("draws a ladder wide enough for the widest slot the string claims", () => {
+ const props = getImageProps(imageNode({ width: 4000 }), { alt: "", sizes: "auto" });
+ for (const breakpoint of DEFAULT_BREAKPOINTS) {
+ expect(props.srcSet).toContain(`${breakpoint}w`);
+ }
+ });
+
+ it("keeps the intrinsic pair, which is what reserves the space in normal flow", () => {
+ const props = getImageProps(imageNode({ width: 4000, height: 2000 }), {
+ alt: "",
+ sizes: "auto",
+ });
+ expect(props).toMatchObject({ width: 4000, height: 2000, loading: "lazy" });
+ });
+
+ it("emits the string as written, rather than a derived one", () => {
+ expect(
+ getImageProps(imageNode({ width: 4000 }), {
+ alt: "",
+ sizes: "(min-width: 60rem) 33vw, 100vw",
+ }).sizes,
+ ).toBe("(min-width: 60rem) 33vw, 100vw");
+ });
+
+ it("offers what fill offers, since only the positioning differs", () => {
+ const node = imageNode({ width: 4000, height: 2000 });
+ const inFlow = getImageProps(node, { alt: "", sizes: "auto" });
+ const fill = getImageProps(node, { alt: "", layout: "fill", sizes: "auto" });
+ expect(inFlow.src).toBe(fill.src);
+ expect(inFlow.srcSet).toBe(fill.srcSet);
+ expect(inFlow.sizes).toBe(fill.sizes);
+ expect(inFlow.loading).toBe(fill.loading);
+ // The one difference: `fill` takes its box from the parent it is stretched over
+ expect(fill.width).toBeUndefined();
+ });
+});
+
+describe("describing the slot exactly once", () => {
+ it("refuses a constrained slot described both ways, which is the disagreement it used to hide", () => {
+ expect(() =>
+ // @ts-expect-error slotWidth and sizes are two descriptions of one slot
+ getImageProps(imageNode({ width: 4000 }), {
+ alt: "",
+ slotWidth: 400,
+ sizes: "(min-width: 1024px) 33vw, 100vw",
+ }),
+ ).toThrow(/layout "constrained" takes a slotWidth or a sizes, never both/);
+ });
+
+ it("refuses a sizes on a fixed slot, whose whole meaning is that it is one number", () => {
+ expect(() =>
+ // @ts-expect-error a fixed slot is stated by its slotWidth alone
+ getImageProps(imageNode({ width: 4000 }), {
+ alt: "",
+ layout: "fixed",
+ slotWidth: 80,
+ sizes: "80px",
+ }),
+ ).toThrow(/layout "fixed" takes no sizes/);
+ });
+
+ it("refuses a slotWidth on the layouts whose width the markup never states", () => {
+ for (const layout of ["full-width", "fill"] as const) {
+ expect(() =>
+ // @ts-expect-error neither layout takes a slot width
+ getImageProps(imageNode({ width: 4000 }), {
+ alt: "",
+ layout,
+ slotWidth: 400,
+ sizes: "50vw",
+ }),
+ ).toThrow(/takes no slotWidth/);
+ }
+ });
+});
+
+describe("the ladder a sizes asks for", () => {
+ /** The candidate widths offered, read back off the `srcSet`. */
+ const ladderOf = (sizes: string, breakpoints?: readonly number[]): number[] => {
+ const props = getImageProps(imageNode({ width: 10000 }), { alt: "", sizes, breakpoints });
+ return [...(props.srcSet ?? "").matchAll(/ (\d+)w/g)].map(([, width]) => Number(width));
+ };
+
+ it("keeps the whole ladder for a slot that can be the viewport", () => {
+ expect(ladderOf("100vw")).toEqual([...DEFAULT_BREAKPOINTS]);
+ });
+
+ it("reads the source size of every media condition, not the widths inside the conditions", () => {
+ // 1024 is a breakpoint of the layout, not a slot width: reading it as one would raise the floor
+ expect(ladderOf("(min-width: 1024px) 33vw, 100vw")).toEqual([...DEFAULT_BREAKPOINTS]);
+ expect(ladderOf("(min-width: 30em) and (max-width: 50em) 25vw, 20vw")).toEqual([
+ 320, 640, 960, 1280,
+ ]);
+ });
+
+ it("stops one band above twice the widest slot the string can describe", () => {
+ // 33vw of the widest viewport it plans for is 845, which a 2x screen needs 1690 pixels for
+ expect(ladderOf("33vw")).toEqual([320, 640, 960, 1280, 1920]);
+ expect(ladderOf("25vw")).toEqual([320, 640, 960, 1280]);
+ });
+
+ it("reads a decimal fraction, which a whole-number scrape would drop", () => {
+ expect(ladderOf("33.3vw")).toEqual([320, 640, 960, 1280, 1920]);
+ });
+
+ it("drops the files narrower than the narrowest the slot can be", () => {
+ expect(ladderOf("400px")).toEqual([640, 960]);
+ });
+
+ it("reads the vw and px lengths inside calc(), min(), max() and clamp()", () => {
+ // A whole-number vw scrape misses this one entirely and silently keeps every candidate
+ expect(ladderOf("calc(33vw - 2rem)")).toEqual([320, 640, 960, 1280, 1920]);
+ expect(ladderOf("calc(100vw - 2rem)")).toEqual([...DEFAULT_BREAKPOINTS]);
+ // The bounds of a math function err outward, so both of these keep more than they need to
+ expect(ladderOf("min(100vw, 400px)")).toEqual([...DEFAULT_BREAKPOINTS]);
+ expect(ladderOf("clamp(200px, 50vw, 600px)")).toEqual([...DEFAULT_BREAKPOINTS]);
+ });
+
+ it("gives up toward the whole ladder, never toward a narrow one", () => {
+ // `auto` is measured by the browser, so no ladder can be derived from it
+ expect(ladderOf("auto")).toEqual([...DEFAULT_BREAKPOINTS]);
+ // Units with no pixel value here, and a string that is not a sizes at all
+ expect(ladderOf("(min-width: 60rem) 50%, 100%")).toEqual([...DEFAULT_BREAKPOINTS]);
+ expect(ladderOf("20em")).toEqual([...DEFAULT_BREAKPOINTS]);
+ expect(ladderOf("(min-width: 60rem 33vw, 100vw")).toEqual([...DEFAULT_BREAKPOINTS]);
+ expect(ladderOf("")).toEqual([...DEFAULT_BREAKPOINTS]);
+ });
+
+ it("plans against the ladder it was given, not against the default one", () => {
+ expect(ladderOf("33vw", [480, 960, 1440])).toEqual([480, 960]);
+ });
+});
+
+describe("the slots three luxe sites were under-serving", () => {
+ /** The widest file offered, which is what decides whether a slot is served sharply. */
+ const widestCandidate = (props: ImgProps): number =>
+ Math.max(...[...(props.srcSet ?? "").matchAll(/ (\d+)w/g)].map(([, width]) => Number(width)));
+
+ it("no longer accepts the spelling that made the two descriptions disagree", () => {
+ expect(() =>
+ // @ts-expect-error the spelling every measured site used
+ getImageProps(imageNode({ width: 4000 }), {
+ alt: "",
+ slotWidth: 400,
+ sizes: "(min-width: 1024px) 33vw, 100vw",
+ }),
+ ).toThrow();
+ });
+
+ it("serves the 768px slot its own sizes claims, at 2x", () => {
+ // What the slotWidth spelling offered was [320, 400, 800]: 0.52x of the 1536 pixels a 768px
+ // slot needs on a 2x screen
+ const props = getImageProps(imageNode({ width: 4000 }), {
+ alt: "",
+ sizes: "(min-width: 1024px) 33vw, 100vw",
+ });
+ expect(widestCandidate(props)).toBeGreaterThanOrEqual(768 * 2);
+ expect(props.sizes).toBe("(min-width: 1024px) 33vw, 100vw");
+ });
+
+ it("serves the 893px slot the two LCP heroes claim, at 2x", () => {
+ const props = getImageProps(imageNode({ width: 4000 }), {
+ alt: "",
+ sizes: "(min-width: 1280px) 33vw, (min-width: 768px) 50vw, 100vw",
+ });
+ expect(widestCandidate(props)).toBeGreaterThanOrEqual(893 * 2);
+ });
+});
+
+describe("a missing node", () => {
+ it("renders the module asset offered as a fallback", () => {
+ expect(
+ getImagePropsWithContext(
+ null,
+ { alt: "Nothing yet", slotWidth: 400, fallback: "img/placeholder.jpg" },
+ {},
+ ),
+ ).toEqual({ src: "/modules/test/img/placeholder.jpg", alt: "Nothing yet" });
+ });
+
+ it("returns nothing at all when there is no fallback either", () => {
+ expect(getImagePropsWithContext(undefined, { alt: "", slotWidth: 400 }, {})).toBeNull();
+ });
+});
+
+describe('the "fill" layout', () => {
+ it("needs no slot width and draws the whole ladder", () => {
+ const props = getImageProps(imageNode({ width: 4000 }), {
+ alt: "",
+ layout: "fill",
+ sizes: "50vw",
+ });
+ for (const breakpoint of DEFAULT_BREAKPOINTS) {
+ expect(props.srcSet).toContain(`${breakpoint}w`);
+ }
+ });
+
+ it("leaves out the intrinsic dimensions, which would fight the parent's box", () => {
+ const props = getImageProps(imageNode({ width: 4000, height: 2000 }), {
+ alt: "",
+ layout: "fill",
+ sizes: "50vw",
+ });
+ expect(props.width).toBeUndefined();
+ expect(props.height).toBeUndefined();
+ });
+
+ it("refuses to guess a sizes it cannot derive, and says what to write", () => {
+ // @ts-expect-error a fill slot is described by its sizes and nothing else
+ expect(() => getImageProps(imageNode({ width: 4000 }), { alt: "", layout: "fill" })).toThrow(
+ /layout "fill" needs the slot described/,
+ );
+ });
+
+ it("keeps sizes even when a small original leaves a single candidate", () => {
+ const props = getImageProps(imageNode({ width: 200 }), {
+ alt: "",
+ layout: "fill",
+ sizes: "auto",
+ });
+ expect(props.srcSet).toBeUndefined();
+ expect(props.sizes).toBe("auto");
+ });
+});
+
+describe('sizes="auto"', () => {
+ it('asks for lazy loading, the only mode in which a browser reads "auto"', () => {
+ const props = getImageProps(imageNode({ width: 4000 }), {
+ alt: "",
+ layout: "fill",
+ sizes: "auto",
+ });
+ expect(props.loading).toBe("lazy");
+ });
+
+ it("recognises the spec's fallback form", () => {
+ expect(
+ getImageProps(imageNode({ width: 4000 }), {
+ alt: "",
+ layout: "fill",
+ sizes: "auto, 50vw",
+ }).loading,
+ ).toBe("lazy");
+ });
+
+ it("leaves a described slot alone", () => {
+ expect(
+ getImageProps(imageNode({ width: 4000 }), {
+ alt: "",
+ layout: "fill",
+ sizes: "(min-width: 60rem) 33vw, 100vw",
+ }).loading,
+ ).toBeUndefined();
+ });
+});
+
+describe("unoptimized", () => {
+ it("serves the original, with no candidates", () => {
+ const props = getImageProps(imageNode({ width: 4000, height: 2000 }), {
+ alt: "",
+ slotWidth: 600,
+ unoptimized: true,
+ });
+ expect(props).toMatchObject({ src: "/files/photo.jpg", width: 4000, height: 2000 });
+ expect(props.srcSet).toBeUndefined();
+ });
+
+ it("reports the original channel rather than the one it would have taken", () => {
+ const node = imageNode({ width: 4000 });
+ expect(inspectImageChannel(node, 600)).toBe("query");
+ expect(inspectImageChannel(node, 600, { unoptimized: true })).toBe("original");
+ });
+});
+
+describe("a custom loader", () => {
+ const loader = ({ src, width, quality }: { src: string; width: number; quality?: number }) =>
+ `https://cdn.example/${width}/q${quality ?? 75}${src}`;
+
+ it("owns every candidate URL", () => {
+ const props = getImageProps(imageNode({ width: 4000 }), {
+ alt: "",
+ layout: "fixed",
+ slotWidth: 600,
+ loader,
+ });
+ expect(props.srcSet).toBe(
+ "https://cdn.example/600/q75/files/photo.jpg 600w, " +
+ "https://cdn.example/1200/q75/files/photo.jpg 1200w",
+ );
+ });
+
+ it("receives the quality the call site asked for", () => {
+ expect(
+ buildImageUrl(imageNode({ width: 4000 }), { width: 600 }, { loader, quality: 40 }).url,
+ ).toBe("https://cdn.example/600/q40/files/photo.jpg");
+ });
+
+ it("is called even at the intrinsic width, where a CDN still negotiates a format", () => {
+ expect(buildImageUrl(imageNode({ width: 600 }), { width: 600 }, { loader })).toEqual({
+ url: "https://cdn.example/600/q75/files/photo.jpg",
+ channel: "loader",
+ width: 600,
+ });
+ });
+
+ it("takes precedence over the thumbnail Jahia pre-generated", () => {
+ expect(buildImageUrl(imageNode({ width: 4000 }), { width: 150 }, { loader }).channel).toBe(
+ "loader",
+ );
+ });
+
+ it("makes inspectImageChannel report the loader instead of a channel nothing takes", () => {
+ expect(inspectImageChannel(imageNode({ width: 4000 }), 600, { loader })).toBe("loader");
+ });
+
+ it("silences the ignored-resize warning, which is about ?w= and not about the loader", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ Reflect.set(globalThis, "server", { config: { isDevelopmentMode: () => true } });
+
+ getImageProps(imageNode({ path: "/sites/test/files/cdn.jpg", width: 4000 }), {
+ alt: "",
+ layout: "fixed",
+ slotWidth: 600,
+ loader,
+ });
+
+ expect(warn).not.toHaveBeenCalled();
+ Reflect.deleteProperty(globalThis, "server");
+ warn.mockRestore();
+ });
+});
+
+describe("quality", () => {
+ it("rides the query channel, next to the width it qualifies", () => {
+ expect(buildImageUrl(imageNode({ width: 4000 }), { width: 600 }, { quality: 60 }).url).toBe(
+ "/files/photo.jpg?w=600&q=60",
+ );
+ });
+
+ it("rides the provider channel as one more decorator argument", () => {
+ const node = imageNode({
+ url: "https://dam.example/a.jpg",
+ width: 4000,
+ defaultProvider: false,
+ });
+ expect(buildImageUrl(node, { width: 600 }, { quality: 60 }).url).toBe(
+ "https://dam.example/a.jpg#signed(w:600,q:60)",
+ );
+ });
+
+ it("does not reach a fixed thumbnail rendition, which has nothing to act on", () => {
+ expect(buildImageUrl(imageNode({ width: 4000 }), { width: 150 }, { quality: 60 }).url).toBe(
+ "/files/photo.jpg?t=thumbnail",
+ );
+ });
+});
+
+describe("module-wide defaults", () => {
+ afterEach(() => {
+ clearImageDefaults();
+ Reflect.deleteProperty(globalThis, "bundleKey");
+ });
+
+ /** The engine exposes the module being evaluated as a context global. */
+ const inModule = (name: string) => Reflect.set(globalThis, "bundleKey", name);
+
+ it("apply to every call of the module that registered them", () => {
+ inModule("acme-module");
+ setImageDefaults({ loader: ({ width }) => `https://cdn.acme/${width}.jpg` });
+
+ expect(
+ buildImageUrl(
+ imageNode({ width: 4000 }),
+ { width: 600 },
+ { context: { bundleKey: "acme-module" } },
+ ).url,
+ ).toBe("https://cdn.acme/600.jpg");
+ });
+
+ it("never reach another module, which shares the same JavaScript context", () => {
+ inModule("acme-module");
+ setImageDefaults({ loader: ({ width }) => `https://cdn.acme/${width}.jpg` });
+
+ expect(
+ buildImageUrl(
+ imageNode({ width: 4000 }),
+ { width: 600 },
+ { context: { bundleKey: "other-module" } },
+ ).channel,
+ ).toBe("query");
+ });
+
+ it("give way to the options of a single call", () => {
+ inModule("acme-module");
+ setImageDefaults({ quality: 90, unoptimized: true });
+
+ expect(
+ buildImageUrl(
+ imageNode({ width: 4000 }),
+ { width: 600 },
+ { quality: 30, unoptimized: false, context: { bundleKey: "acme-module" } },
+ ).url,
+ ).toBe("/files/photo.jpg?w=600&q=30");
+ });
+
+ it("supply the candidate ladder when the call site names none", () => {
+ inModule("acme-module");
+ setImageDefaults({ breakpoints: [400, 800] });
+
+ expect(
+ getImageProps(
+ imageNode({ width: 4000 }),
+ { alt: "", layout: "full-width" },
+ { bundleKey: "acme-module" },
+ ).srcSet,
+ ).toBe("/files/photo.jpg?w=400 400w, /files/photo.jpg?w=800 800w");
+ });
+
+ it("refuse to register outside a module, rather than leaking to every module", () => {
+ expect(() => setImageDefaults({ quality: 50 })).toThrow(
+ /no module to attach these defaults to/,
+ );
+ });
+});
+
+describe("absolute URLs", () => {
+ it("take the host from the site the image belongs to, not from the request", () => {
+ expect(buildImageUrl(imageNode({ width: 4000 }), { width: 600 }, { absolute: true }).url).toBe(
+ "https://www.example.com/files/photo.jpg?w=600",
+ );
+ });
+
+ it("accept an origin the caller names, for a host resolution cannot know about", () => {
+ expect(
+ buildImageUrl(
+ imageNode({ width: 4000 }),
+ { width: 600 },
+ { absolute: "https://cdn.acme.com/" },
+ ).url,
+ ).toBe("https://cdn.acme.com/files/photo.jpg?w=600");
+ });
+
+ it("reach the thumbnail channel, which does not go through buildNodeUrl", () => {
+ expect(buildImageUrl(imageNode({ width: 4000 }), { width: 150 }, { absolute: true }).url).toBe(
+ "https://www.example.com/files/photo.jpg?t=thumbnail",
+ );
+ });
+
+ it("leave a provider's own absolute URL untouched", () => {
+ const node = imageNode({
+ url: "https://dam.example/a.jpg",
+ width: 4000,
+ defaultProvider: false,
+ });
+ expect(buildImageUrl(node, { width: 600 }, { absolute: true }).url).toBe(
+ "https://dam.example/a.jpg#signed(w:600)",
+ );
+ });
+
+ it("run through every candidate of a srcSet", () => {
+ const props = getImageProps(imageNode({ width: 4000 }), {
+ alt: "",
+ layout: "fixed",
+ slotWidth: 600,
+ absolute: true,
+ });
+ expect(props.src).toBe("https://www.example.com/files/photo.jpg?w=600");
+ expect(props.srcSet).not.toContain(" /files/");
+ });
+});
+
+describe("buildBackgroundImageUrl", () => {
+ it("returns a quoted CSS value", () => {
+ expect(buildBackgroundImageUrl(imageNode({ width: 4000 }), { width: 1920 })).toBe(
+ 'url("/files/photo.jpg?w=1920")',
+ );
+ });
+
+ it("percent-encodes the commas that would split the layer list", () => {
+ const node = imageNode({
+ url: "https://cdn.example/image/upload/v1/a.jpg",
+ width: 4000,
+ defaultProvider: false,
+ getUrl: (params) =>
+ `https://cdn.example/image/upload/f_auto,${params[0].replace(":", "_")}/v1/a.jpg`,
+ });
+ expect(buildBackgroundImageUrl(node, { width: 600 })).toBe(
+ 'url("https://cdn.example/image/upload/f_auto%2Cw_600/v1/a.jpg")',
+ );
+ });
+
+ it("neutralises a quote that would close the value early", () => {
+ const node = imageNode({ url: '/files/a"b.jpg', width: 4000 });
+ expect(buildBackgroundImageUrl(node)).toBe('url("/files/a%22b.jpg")');
+ });
+});
+
+describe("buildThumbnailUrl", () => {
+ it("returns the smallest thumbnail Jahia generated", () => {
+ expect(buildThumbnailUrl(imageNode())).toBe("/files/photo.jpg?t=thumbnail");
+ });
+
+ it("falls back to the next one when the smallest is missing", () => {
+ expect(buildThumbnailUrl(imageNode({ thumbnails: ["thumbnail2"] }))).toBe(
+ "/files/photo.jpg?t=thumbnail2",
+ );
+ });
+
+ it("says nothing rather than inventing a URL when there is no thumbnail", () => {
+ expect(buildThumbnailUrl(imageNode({ thumbnails: [] }))).toBeUndefined();
+ });
+});
+
+describe("the cache dependency", () => {
+ const renderContext = {} as never;
+ let addCacheDependency: ReturnType;
+
+ beforeEach(() => {
+ addCacheDependency = vi.fn();
+ Reflect.set(globalThis, "server", { render: { addCacheDependency } });
+ });
+
+ afterEach(() => {
+ Reflect.deleteProperty(globalThis, "server");
+ });
+
+ it("is registered by buildImageUrl, so a CSS background is flushed like an
", () => {
+ const node = imageNode({ width: 4000 });
+ buildImageUrl(node, { width: 600 }, { context: { renderContext } });
+ expect(addCacheDependency).toHaveBeenCalledWith({ node }, renderContext);
+ });
+
+ it("is registered once per props set, not once per candidate", () => {
+ getImageProps(imageNode({ width: 4000 }), { alt: "", layout: "full-width" }, { renderContext });
+ expect(addCacheDependency).toHaveBeenCalledTimes(1);
+ });
+
+ it("can be turned off by a caller that registers it itself", () => {
+ buildImageUrl(
+ imageNode({ width: 4000 }),
+ { width: 600 },
+ {
+ context: { renderContext },
+ cacheDependency: false,
+ },
+ );
+ expect(addCacheDependency).not.toHaveBeenCalled();
+ });
+});
+
+describe("a data URI in a CSS value", () => {
+ it("keeps the comma that separates its header from its payload", () => {
+ // Percent-encoding that comma would destroy the image rather than protect it, and no Jahia
+ // rewriter ever sees a data URI
+ expect(buildBackgroundImageUrl(imageNode({ url: "data:image/png;base64,AAAA" }))).toBe(
+ 'url("data:image/png;base64,AAAA")',
+ );
+ });
});
diff --git a/javascript-modules-library/src/utils/image/imageDefaults.ts b/javascript-modules-library/src/utils/image/imageDefaults.ts
new file mode 100644
index 00000000..f3e1c04f
--- /dev/null
+++ b/javascript-modules-library/src/utils/image/imageDefaults.ts
@@ -0,0 +1,133 @@
+import type { RenderContext, Resource } from "org.jahia.services.render";
+import type { AbsoluteUrlOption } from "../urlBuilder/absoluteUrl.js";
+
+/**
+ * The engine sets `bundleKey` as a context global while it evaluates a module's server bundle, and
+ * `useServerContext()` reports the same value while a view of that module renders.
+ *
+ * @see {@link setImageDefaults} for why the image defaults are keyed by it.
+ */
+declare const bundleKey: string | undefined;
+
+/** What a loader is given to build one candidate URL. Mirrors `next/image`'s `ImageLoaderProps`. */
+export interface ImageLoaderProps {
+ /** The asset's own URL, unresized — the same one the `original` channel returns. */
+ src: string;
+ /** The candidate width in image pixels, already clamped to the intrinsic width. */
+ width: number;
+ /** The requested quality, when the call site or the module defaults ask for one. */
+ quality?: number;
+}
+
+/**
+ * Builds the URL of one candidate, replacing the channel routing entirely.
+ *
+ * @example
+ * ```ts
+ * const cloudinary: ImageLoader = ({ src, width, quality }) =>
+ * `https://res.cloudinary.com/acme/image/fetch/f_auto,q_${quality ?? "auto"},w_${width}/${src}`;
+ * ```;
+ */
+export type ImageLoader = (props: ImageLoaderProps) => string;
+
+/** The parts of an image request a module can decide once instead of at every call site. */
+export interface ImageDefaults {
+ /** Replaces the built-in channel routing. */
+ loader?: ImageLoader;
+ /** Passed to the loader, and to the `provider` and `query` channels as a `q` hint. */
+ quality?: number;
+ /** Serves the original bytes, with no candidates at all. */
+ unoptimized?: boolean;
+ /** The candidate ladder `constrained`, `full-width` and `fill` draw from. */
+ breakpoints?: readonly number[];
+}
+
+/**
+ * The Jahia render context an image function needs.
+ *
+ * `bundleKey` selects the module whose {@link setImageDefaults} apply; inside a render it comes from
+ * `useServerContext()`, and outside one it is the caller's to pass.
+ */
+export interface ImageContext {
+ renderContext?: RenderContext;
+ currentResource?: Resource;
+ bundleKey?: string;
+}
+
+/** What a single call may override, on top of its module's defaults. */
+export interface ImageSourceOptions {
+ /** Replaces the built-in channel routing. Defaults to the module's loader. */
+ loader?: ImageLoader;
+ /** Quality hint, passed to the loader and to the channels that carry hints. */
+ quality?: number;
+ /** Serve the original bytes: no resize, no candidates. */
+ unoptimized?: boolean;
+ /**
+ * Return a URL with a scheme and a host — what `og:image` and JSON-LD need.
+ *
+ * @see {@link AbsoluteUrlOption}
+ */
+ absolute?: AbsoluteUrlOption;
+}
+
+/**
+ * Every JavaScript module in an instance shares one GraalJS context, so a plain module-level
+ * variable in this library would be engine-wide: one module's loader would rewrite another module's
+ * images. Keying by bundle keeps a default inside the module that declared it.
+ */
+const defaultsByBundle = new Map();
+
+/**
+ * Sets the image defaults of the calling module: every `JImage`, `getImageProps` and
+ * `buildImageUrl` in it uses them unless the call overrides them.
+ *
+ * Call it at the top level of a server file — the engine only knows which module is speaking while
+ * it evaluates that module's bundle.
+ *
+ * @example
+ * ```ts
+ * // src/server/images.ts, imported once from a view
+ * setImageDefaults({
+ * loader: ({ src, width, quality }) => `https://cdn.acme.com/${width}/${quality ?? 75}${src}`,
+ * quality: 80,
+ * });
+ * ```;
+ *
+ * @param defaults - Merged into whatever the module already set.
+ * @throws When called outside a module's bundle evaluation, where there is no module to attach the
+ * defaults to.
+ */
+export function setImageDefaults(defaults: ImageDefaults): void {
+ if (typeof bundleKey !== "string" || !bundleKey) {
+ throw new Error(
+ "setImageDefaults: no module to attach these defaults to. Call it at the top level of a " +
+ "server file of your module, not inside a render or a callback.",
+ );
+ }
+
+ defaultsByBundle.set(bundleKey, { ...defaultsByBundle.get(bundleKey), ...defaults });
+}
+
+/** The defaults a module registered, or an empty set for a module that registered none. */
+export function getImageDefaults(context?: ImageContext): ImageDefaults {
+ return (context?.bundleKey ? defaultsByBundle.get(context.bundleKey) : undefined) ?? {};
+}
+
+/** A call's own options over its module's defaults, each key resolved on its own. */
+export function resolveImageDefaults(
+ options: ImageSourceOptions | undefined,
+ context: ImageContext | undefined,
+): Required> & ImageDefaults {
+ const defaults = getImageDefaults(context);
+ return {
+ loader: options?.loader ?? defaults.loader,
+ quality: options?.quality ?? defaults.quality,
+ unoptimized: options?.unoptimized ?? defaults.unoptimized ?? false,
+ breakpoints: defaults.breakpoints,
+ };
+}
+
+/** Drops every module's defaults. Exported for tests, which share one module registry. */
+export function clearImageDefaults(): void {
+ defaultsByBundle.clear();
+}
diff --git a/javascript-modules-library/src/utils/image/sizesLadder.ts b/javascript-modules-library/src/utils/image/sizesLadder.ts
new file mode 100644
index 00000000..4d355801
--- /dev/null
+++ b/javascript-modules-library/src/utils/image/sizesLadder.ts
@@ -0,0 +1,150 @@
+/**
+ * The slot a single source size describes, in CSS pixels, at the two ends of the viewport range.
+ *
+ * Both bounds are deliberately generous: `min` may be smaller than the slot ever gets and `max`
+ * larger than it ever gets. A bound that errs outward widens the candidate ladder, and a ladder
+ * that is too wide costs a few bytes of markup, where one that is too narrow serves an image the
+ * browser has to upscale.
+ */
+interface SlotBounds {
+ min: number;
+ max: number;
+}
+
+/** A source size that is a plain viewport fraction: `33vw`, `33.3vw`, `100VW`. */
+const VIEWPORT_FRACTION = /^([0-9]*\.?[0-9]+)vw$/i;
+
+/** A source size that is a plain length in pixels: `400px`. */
+const PIXELS = /^([0-9]*\.?[0-9]+)px$/i;
+
+/** The CSS math functions a source size may be written with. */
+const MATH_FUNCTION = /^(?:calc|min|max|clamp)\(/i;
+
+/** Every `vw` and `px` length inside a math function, at any depth. */
+const LENGTHS = /([0-9]*\.?[0-9]+)(vw|px)\b/gi;
+
+/**
+ * Splits on the separators that are not inside parentheses — the commas between `sizes` entries,
+ * and the spaces between a media condition and its source size. `min(100vw, 400px)` is one token
+ * either way.
+ *
+ * @returns The non-empty parts, or `null` when the parentheses do not balance.
+ */
+const splitTopLevel = (input: string, separator: RegExp): string[] | null => {
+ const parts: string[] = [];
+ let depth = 0;
+ let current = "";
+
+ for (const char of input) {
+ if (char === "(") depth++;
+ else if (char === ")") depth--;
+ if (depth < 0) return null;
+
+ if (depth === 0 && separator.test(char)) {
+ if (current.trim()) parts.push(current.trim());
+ current = "";
+ } else {
+ current += char;
+ }
+ }
+
+ if (depth !== 0) return null;
+ if (current.trim()) parts.push(current.trim());
+ return parts;
+};
+
+/**
+ * The slot one source size describes, given the narrowest and widest viewport the ladder plans for.
+ *
+ * A math function is not evaluated — `rem`, `%` and `em` inside it have no pixel value here. Its
+ * `vw` and `px` lengths are read instead, and combined into bounds that cannot be too tight: the
+ * smallest single length for the lower bound, the sum of them all for the upper one. `calc(100vw -
+ * 2rem)` is therefore read as at most `100vw`, and `clamp(200px, 50vw, 600px)` as at most their sum
+ * — both wider than the truth, which is the safe direction.
+ *
+ * @returns The bounds, or `null` for a source size this cannot read — `auto`, `50%`, `20em`.
+ */
+const boundsOf = (sourceSize: string, narrowest: number, widest: number): SlotBounds | null => {
+ const fraction = VIEWPORT_FRACTION.exec(sourceSize);
+ if (fraction) {
+ const ratio = Number(fraction[1]) / 100;
+ return { min: ratio * narrowest, max: ratio * widest };
+ }
+
+ const pixels = PIXELS.exec(sourceSize);
+ if (pixels) return { min: Number(pixels[1]), max: Number(pixels[1]) };
+
+ if (!MATH_FUNCTION.test(sourceSize)) return null;
+
+ const atNarrowest: number[] = [];
+ let atWidest = 0;
+ for (const [, value, unit] of sourceSize.matchAll(LENGTHS)) {
+ const amount = Number(value);
+ if (unit.toLowerCase() === "vw") {
+ atNarrowest.push((amount / 100) * narrowest);
+ atWidest += (amount / 100) * widest;
+ } else {
+ atNarrowest.push(amount);
+ atWidest += amount;
+ }
+ }
+
+ return atNarrowest.length ? { min: Math.min(...atNarrowest), max: atWidest } : null;
+};
+
+/**
+ * The candidate file widths a `sizes` attribute asks for.
+ *
+ * This is the one place where the two descriptions of a slot are kept in agreement: a call site
+ * that writes its own `sizes` gets a ladder derived from that string, rather than one derived from
+ * a slot width the string never mentions.
+ *
+ * The ladder's own extremes double as the viewport range to plan for — the narrowest breakpoint is
+ * taken as the narrowest viewport, the widest as the widest — because they are the only real
+ * numbers the caller has given us about the site. Each entry's source size is read at both ends,
+ * the narrowest slot any entry can describe becomes the floor and the widest becomes the ceiling,
+ * and the ladder keeps every breakpoint from the floor up to and including the first one that
+ * reaches twice the ceiling. Twice, because the widest slot still has to be sharp on a 2x display.
+ *
+ * A `sizes` this cannot read — `auto`, a `%`, an `em`, an unbalanced parenthesis — returns the
+ * whole ladder. That is the point: an unreadable string must never narrow the ladder, or the images
+ * it describes ship under-served and nothing says so.
+ *
+ * @param sizes - The `sizes` attribute, as the call site wrote it.
+ * @param breakpoints - The candidate ladder to draw from.
+ * @returns The candidates to offer, in the order the breakpoints were given.
+ */
+export function ladderFromSizes(sizes: string, breakpoints: readonly number[]): number[] {
+ const wholeLadder = [...breakpoints];
+ if (breakpoints.length === 0) return wholeLadder;
+
+ const narrowest = Math.min(...breakpoints);
+ const widest = Math.max(...breakpoints);
+
+ const entries = splitTopLevel(sizes, /,/);
+ if (!entries?.length) return wholeLadder;
+
+ const bounds: SlotBounds[] = [];
+ for (const entry of entries) {
+ // An entry is an optional media condition followed by the source size, so the slot is the last
+ // token — the one the media condition's own lengths (`(min-width: 1024px)`) are never mistaken
+ // for
+ const sourceSize = splitTopLevel(entry, /\s/)?.at(-1);
+ const entryBounds = sourceSize ? boundsOf(sourceSize, narrowest, widest) : null;
+ if (!entryBounds) return wholeLadder;
+ bounds.push(entryBounds);
+ }
+
+ const floor = Math.min(...bounds.map(({ min }) => min));
+ const ceiling = Math.max(...bounds.map(({ max }) => max));
+
+ const ladder: number[] = [];
+ for (const candidate of [...breakpoints].sort((a, b) => a - b)) {
+ // A file narrower than the narrowest the slot can be is one the browser would never pick
+ if (candidate < floor) continue;
+ ladder.push(candidate);
+ if (candidate >= 2 * ceiling) break;
+ }
+
+ return ladder.length ? ladder : wholeLadder;
+}
diff --git a/javascript-modules-library/src/utils/image/warnIgnoredResize.ts b/javascript-modules-library/src/utils/image/warnIgnoredResize.ts
deleted file mode 100644
index cb8b995b..00000000
--- a/javascript-modules-library/src/utils/image/warnIgnoredResize.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import type { JCRNodeWrapper } from "org.jahia.services.content";
-
-/**
- * Whether the instance has already been told. What the warning reports is a property of the
- * instance, not of the image — if these parameters are ignored for one asset they are ignored for
- * every one — so one line per engine lifetime says everything a second would.
- */
-let reported = false;
-
-/**
- * True on a development instance.
- *
- * Every part of this call can be missing — the whole `server` bridge outside the engine, the method
- * on an engine older than it — and a diagnostic that cannot tell must stay quiet rather than fail.
- */
-const isDevelopmentMode = (): boolean => {
- try {
- return server.config.isDevelopmentMode();
- } catch {
- return false;
- }
-};
-
-/** How the message names the image it uses as its example. */
-const identify = (node: JCRNodeWrapper): string => {
- try {
- return node.getPath() || "an image";
- } catch {
- return "an image";
- }
-};
-
-/**
- * Warns, once, that the widths of an image travel as `?w=` parameters this instance most likely
- * ignores.
- *
- * Nothing else reports that trap: the markup is correct, only the bytes never shrink. The warning
- * is emitted in development mode only — a production instance pays nothing — and stays silent about
- * anything it cannot read, because a diagnostic that breaks a render is worse than no diagnostic.
- *
- * @param node - The file node whose candidates landed on the `query` channel.
- * @see {@link ImageResizeChannel} for what each channel does with a requested size.
- */
-export function warnIgnoredResize(node: JCRNodeWrapper): void {
- if (!isDevelopmentMode()) return;
-
- if (reported) return;
- reported = true;
-
- console.warn(
- `getImageProps: the ?w= candidates of ${identify(node)} — and of every other image on this ` +
- `instance — return the original bytes, because only Media Optimization reads those ` +
- `parameters. Expected on an instance without it; the markup is still correct. ` +
- `See docs/2-guides/8-images/README.md, "What actually resizes the image, and where".`,
- );
-}
diff --git a/javascript-modules-library/src/utils/urlBuilder/absoluteUrl.spec.ts b/javascript-modules-library/src/utils/urlBuilder/absoluteUrl.spec.ts
new file mode 100644
index 00000000..b2b340a6
--- /dev/null
+++ b/javascript-modules-library/src/utils/urlBuilder/absoluteUrl.spec.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from "vitest";
+import type { JCRNodeWrapper } from "org.jahia.services.content";
+import type { RenderContext } from "org.jahia.services.render";
+import { toAbsoluteUrl } from "./absoluteUrl.js";
+
+/** A node, seen only through the site it resolves to. */
+const nodeOnSite = (serverName?: string | Error) =>
+ ({
+ getResolveSite: () => {
+ if (serverName instanceof Error) throw serverName;
+ return { getServerName: () => serverName };
+ },
+ }) as unknown as JCRNodeWrapper;
+
+/** A render context, seen only through the request an origin can be read from. */
+const requestFrom = (scheme: string, host: string, port: number) => ({
+ renderContext: {
+ getRequest: () => ({
+ getScheme: () => scheme,
+ getServerName: () => host,
+ getServerPort: () => port,
+ }),
+ } as unknown as RenderContext,
+});
+
+describe("toAbsoluteUrl", () => {
+ it("leaves the URL alone when nothing was asked for", () => {
+ expect(toAbsoluteUrl("/sites/a/home.html", nodeOnSite("www.example.com"), false)).toBe(
+ "/sites/a/home.html",
+ );
+ expect(toAbsoluteUrl("/sites/a/home.html", nodeOnSite("www.example.com"), undefined)).toBe(
+ "/sites/a/home.html",
+ );
+ });
+
+ it("names the server of the site the node belongs to", () => {
+ expect(
+ toAbsoluteUrl(
+ "/sites/a/home.html",
+ nodeOnSite("www.example.com"),
+ true,
+ requestFrom("http", "localhost", 8080),
+ ),
+ ).toBe("https://www.example.com/sites/a/home.html");
+ });
+
+ it("falls back to the request for a site that declares no server name", () => {
+ for (const serverName of [undefined, "", "localhost"]) {
+ expect(
+ toAbsoluteUrl(
+ "/sites/a/home.html",
+ nodeOnSite(serverName),
+ true,
+ requestFrom("http", "localhost", 8080),
+ ),
+ ).toBe("http://localhost:8080/sites/a/home.html");
+ }
+ });
+
+ it("leaves out a port that is the scheme's default", () => {
+ expect(
+ toAbsoluteUrl("/a.html", nodeOnSite(), true, requestFrom("https", "example.com", 443)),
+ ).toBe("https://example.com/a.html");
+ });
+
+ it("uses an origin the caller names, trailing slash and all", () => {
+ expect(toAbsoluteUrl("/a.html", nodeOnSite("www.example.com"), "https://cdn.acme.com/")).toBe(
+ "https://cdn.acme.com/a.html",
+ );
+ });
+
+ it("leaves a URL that already carries a host alone", () => {
+ expect(toAbsoluteUrl("https://dam.example/a.jpg", nodeOnSite("www.example.com"), true)).toBe(
+ "https://dam.example/a.jpg",
+ );
+ expect(toAbsoluteUrl("//dam.example/a.jpg", nodeOnSite("www.example.com"), true)).toBe(
+ "//dam.example/a.jpg",
+ );
+ });
+
+ it("survives a node whose site cannot be resolved", () => {
+ expect(
+ toAbsoluteUrl(
+ "/a.html",
+ nodeOnSite(new Error("no site")),
+ true,
+ requestFrom("http", "h", 80),
+ ),
+ ).toBe("http://h/a.html");
+ });
+
+ it("refuses to invent an origin, and says how to supply one", () => {
+ expect(() => toAbsoluteUrl("/a.html", nodeOnSite(), true)).toThrow(
+ /Pass the origin explicitly/,
+ );
+ });
+});
diff --git a/javascript-modules-library/src/utils/urlBuilder/absoluteUrl.ts b/javascript-modules-library/src/utils/urlBuilder/absoluteUrl.ts
new file mode 100644
index 00000000..d1119c29
--- /dev/null
+++ b/javascript-modules-library/src/utils/urlBuilder/absoluteUrl.ts
@@ -0,0 +1,88 @@
+import type { JCRNodeWrapper } from "org.jahia.services.content";
+import type { RenderContext } from "org.jahia.services.render";
+
+// Matches a URL that already carries a scheme, or a protocol-relative one
+const absoluteUrlRegExp = /^(?:[a-z+]+:)?\/\//i;
+
+/**
+ * How an absolute URL gets its scheme and host.
+ *
+ * - `true`: resolve it — the target site's server name when it declares one, the current request
+ * otherwise.
+ * - A string: use that origin verbatim (`"https://www.example.com"`), for the cases resolution cannot
+ * know about (a reverse proxy, a preview host, a canonical domain).
+ */
+export type AbsoluteUrlOption = boolean | string;
+
+/** Ports a URL does not need to spell out. */
+const DEFAULT_PORTS: Record = { http: 80, https: 443 };
+
+/** The origin of the request being served, port included when it is not the scheme's default. */
+const requestOrigin = (context: { renderContext?: RenderContext }): string | undefined => {
+ const request = context.renderContext?.getRequest();
+ if (!request) return undefined;
+
+ const scheme = request.getScheme();
+ const port = request.getServerPort();
+ const authority =
+ port && port !== DEFAULT_PORTS[scheme]
+ ? `${request.getServerName()}:${port}`
+ : request.getServerName();
+ return `${scheme}://${authority}`;
+};
+
+/**
+ * The origin declared by the site the node belongs to.
+ *
+ * Preferred over the request's own origin because a URL can point at _another_ site — a canonical
+ * link, a JSON-LD reference, an `og:image` on a shared asset — and that site is reachable under its
+ * own server name, not under the one this request happened to use. Core's
+ * `JCRNodeWrapper.getAbsoluteUrl(request)` takes the request's, which is why it cannot be reused
+ * here.
+ *
+ * A site with no server name configured reports `localhost`, which is a placeholder rather than an
+ * answer. `https` is assumed: a server name is a public host, and a public host that is not served
+ * over TLS is not a case worth generating URLs for.
+ */
+const siteOrigin = (node: JCRNodeWrapper): string | undefined => {
+ try {
+ const serverName = node.getResolveSite()?.getServerName();
+ return serverName && serverName !== "localhost" ? `https://${serverName}` : undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+/**
+ * Prefixes a Jahia URL with an origin, so it can travel outside the page that produced it.
+ *
+ * @param url - A URL built by this module — usually root-relative.
+ * @param node - The node the URL points at; its site supplies the server name.
+ * @param absolute - Falsy to leave the URL alone, otherwise see {@link AbsoluteUrlOption}.
+ * @param context - Supplies the request the origin falls back to.
+ * @returns The absolute URL, or `url` untouched when `absolute` is falsy or it already is one.
+ */
+export function toAbsoluteUrl(
+ url: string,
+ node: JCRNodeWrapper,
+ absolute: AbsoluteUrlOption | undefined,
+ context: { renderContext?: RenderContext } = {},
+): string {
+ if (!absolute) return url;
+ // An external provider often returns its own absolute URL already
+ if (absoluteUrlRegExp.test(url)) return url;
+
+ const origin =
+ typeof absolute === "string"
+ ? absolute.replace(/\/+$/, "")
+ : (siteOrigin(node) ?? requestOrigin(context));
+
+ if (!origin) {
+ throw new Error(
+ "Cannot build an absolute URL: the target site declares no server name, and there is no " +
+ 'request to borrow one from. Pass the origin explicitly, as absolute: "https://example.com".',
+ );
+ }
+
+ return origin + url;
+}
diff --git a/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts b/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts
index b12524b1..020deeb6 100644
--- a/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts
+++ b/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts
@@ -1,10 +1,13 @@
import type { JCRNodeWrapper } from "org.jahia.services.content";
import type { RenderContext, Resource } from "org.jahia.services.render";
import { useServerContext } from "../../hooks/useServerContext";
+import { toAbsoluteUrl, type AbsoluteUrlOption } from "./absoluteUrl.js";
// Regex that checks if the first word contains colon (http:, mail:, ftp: ..)
const absoluteUrlRegExp = /^(?:[a-z+]+:)?\/\//i;
+export { toAbsoluteUrl, type AbsoluteUrlOption } from "./absoluteUrl.js";
+
/** URLSearchParams is not supported by Graal, this is our polyfill in the meantime */
function appendParameters(url: string, parameters: Record): string {
const querystring = Object.entries(parameters)
@@ -31,6 +34,13 @@ export function buildNodeUrl(
| {
/** The query string parameters to append to the URL */
parameters?: Record;
+ /**
+ * Return a URL with a scheme and a host, for the places a root-relative one does not work:
+ * `og:url`, `og:image`, a canonical link, JSON-LD, an email.
+ *
+ * @see {@link AbsoluteUrlOption}
+ */
+ absolute?: AbsoluteUrlOption;
/**
* The mode to use to build the URL. Defines the mode or override the one provided by the
* renderContext.
@@ -50,6 +60,13 @@ export function buildNodeUrl(
| {
/** The query string parameters to append to the URL */
parameters?: Record;
+ /**
+ * Return a URL with a scheme and a host, for the places a root-relative one does not work:
+ * `og:url`, `og:image`, a canonical link, JSON-LD, an email.
+ *
+ * @see {@link AbsoluteUrlOption}
+ */
+ absolute?: AbsoluteUrlOption;
/**
* Additional arguments passed to `node.getUrl(List)`, for a provider whose decorator
* interprets them — an external DAM mount turns `{ w: 600 }` into a signed, transformed
@@ -73,6 +90,7 @@ export function buildNodeUrl(
node: JCRNodeWrapper,
config: {
parameters?: Record;
+ absolute?: AbsoluteUrlOption;
mode?: "edit" | "preview" | "live";
language?: string;
extension?: string;
@@ -104,16 +122,21 @@ export function buildNodeUrl(
if (!mode) throw new Error("buildNodeUrl: mode is not defined and cannot be inferred.");
if (!language) throw new Error("buildNodeUrl: language is not defined and cannot be inferred.");
- return buildEndpointUrl(
- (mode === "edit"
- ? "/cms/edit/default/"
- : mode === "preview"
- ? "/cms/render/default/"
- : "/cms/render/live/") +
- language +
- node.getPath() +
- extension,
- { parameters: config.parameters },
+ return toAbsoluteUrl(
+ buildEndpointUrl(
+ (mode === "edit"
+ ? "/cms/edit/default/"
+ : mode === "preview"
+ ? "/cms/render/default/"
+ : "/cms/render/live/") +
+ language +
+ node.getPath() +
+ extension,
+ { parameters: config.parameters },
+ context,
+ ),
+ node,
+ config.absolute,
context,
);
}
@@ -124,7 +147,7 @@ export function buildNodeUrl(
: node.getUrl();
if (context.renderContext) url = context.renderContext.getResponse().encodeURL(url);
if (config.parameters) url = appendParameters(url, config.parameters);
- return url;
+ return toAbsoluteUrl(url, node, config.absolute, context);
}
/**
diff --git a/javascript-modules-library/tsconfig.spec.json b/javascript-modules-library/tsconfig.spec.json
new file mode 100644
index 00000000..bd12e71e
--- /dev/null
+++ b/javascript-modules-library/tsconfig.spec.json
@@ -0,0 +1,8 @@
+{
+ // The build excludes the specs (they must not reach `dist`), so they need their own pass to be
+ // type-checked at all.
+ "extends": "./tsconfig.json",
+ "compilerOptions": { "noEmit": true },
+ "exclude": ["node_modules"],
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
+}
diff --git a/samples/hydrogen/src/components/BlogPost/default.server.tsx b/samples/hydrogen/src/components/BlogPost/default.server.tsx
index ef000ac0..2f5f71fe 100644
--- a/samples/hydrogen/src/components/BlogPost/default.server.tsx
+++ b/samples/hydrogen/src/components/BlogPost/default.server.tsx
@@ -14,7 +14,7 @@ jahiaComponent(
) => {
return (
-
+