From b9aea647ce1240a166d9cbd0b23fefb74c8e8995 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 20:08:54 +0200 Subject: [PATCH 01/10] feat(library): buildNodeUrl can return an absolute URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `og:url`, `og:image`, a canonical link and JSON-LD all need a scheme and a host, and there was no way to ask for one: Jahia/jahia.com hardcodes its own origin six times and reaches past the library to the Java `JCRNodeWrapper.getAbsoluteUrl(request)` for the rest. `absolute: true` resolves the origin from the *target* site's server name, falling back to the request when the site declares none — a link to a page of another site must name that site's server, which is exactly what core's request-based helper cannot do. `absolute: "https://example.com"` names it outright, for a reverse proxy or a canonical domain. It lives in its own module so both the URL tier and the image tier can reach it without importing React. Part of Jahia/javascript-modules#765, and the shared half of Jahia/javascript-modules#756 — the link API will want the same option. --- .../src/utils/urlBuilder/absoluteUrl.spec.ts | 97 +++++++++++++++++++ .../src/utils/urlBuilder/absoluteUrl.ts | 88 +++++++++++++++++ .../src/utils/urlBuilder/urlBuilder.ts | 45 ++++++--- 3 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 javascript-modules-library/src/utils/urlBuilder/absoluteUrl.spec.ts create mode 100644 javascript-modules-library/src/utils/urlBuilder/absoluteUrl.ts 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); } /** From f1064ca4a6206dd767bd56b174ffc3c52f8197a4 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 20:09:07 +0200 Subject: [PATCH 02/10] feat(library): a pluggable image loader, with quality and unoptimized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resize routing was decided inside `buildImageUrl` and a module could not get in. A project on a CDN, a custom DAM or a Media Optimization setup that speaks another URL dialect had no way to say so. `loader({ src, width, quality })` replaces the routing, `quality` is passed to it, and `unoptimized` opts one image out of candidate generation entirely — `next/image`'s three escape hatches, under their own names. `setImageDefaults` sets them once per module. It is keyed by the bundle the call came from, because every JavaScript module in an instance shares one GraalJS context: a plain module-level variable here would let one module's loader rewrite another module's images. Part of Jahia/javascript-modules#763. --- .../src/utils/image/imageDefaults.ts | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 javascript-modules-library/src/utils/image/imageDefaults.ts 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(); +} From 09ebeef9a0fb02ce7f29e1d83df795f88ce9f33b Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 20:09:07 +0200 Subject: [PATCH 03/10] feat(library): complete the image tier outside the component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three holes each found by a real call site on Jahia/jahia.com. `buildImageUrl` now registers the render cache dependency `getImageProps` already registered. It is the only option for a CSS background image, so a background silently lost the flush an `` got. `buildBackgroundImageUrl` returns a ready `url("…")` value, quoted and with its commas percent-encoded — a DAM URL containing a comma breaks the CSS layer list the way it breaks `srcSet` (Jahia/jahia#23). A `data:` URI is left alone: the comma in `data:image/png;base64,…` separates the header from the payload, and encoding it would destroy the image rather than protect it. `buildThumbnailUrl` exposes the smallest pre-generated thumbnail, which is the one variant a plain instance produces offline. Also carries the loader, quality, unoptimized and absolute options through the channel routing, and adds `loader` to the reported channel so `inspectImageChannel` stays honest when a module owns the URLs. Part of Jahia/javascript-modules#765 and Jahia/javascript-modules#763. --- .../src/utils/image/buildImageUrl.ts | 176 ++++++++++++++++-- 1 file changed, 157 insertions(+), 19 deletions(-) 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); From 1ecdac2f71a4ab87d59efb050253c0c8cca8dcfb Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 20:09:28 +0200 Subject: [PATCH 04/10] feat(library): a fill layout and first-class sizes="auto" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `layout` plus a slot width assumes the call site knows its slot in pixels. Adopting the API on Jahia/jahia.com moved all 25 image sites and every one of them abandoned `layout`: the site has exactly one fixed-pixel slot, a 15rem avatar, and no other image has a width in CSS pixels at any breakpoint. `layout="fill"` is the slot whose width no view can know — a percentage, a grid cell, an aspect-ratio box. It needs no slot width, draws the whole candidate ladder, omits the intrinsic dimensions that would fight the parent's box, and requires `sizes`, because nothing else can describe it. `sizes="auto"` is now a resolved conflict rather than a silent one. It is only valid with `loading="lazy"`, which the component emitted conditionally, so the two quietly cancelled out. `getImageProps` now reports `loading: "lazy"` alongside an `auto` sizes, so an island that spreads the props gets the pairing too. The slot width is renamed `slotWidth`, freeing `width` for the HTML attribute it always looked like. Part of Jahia/javascript-modules#761 and Jahia/javascript-modules#762. --- .../src/utils/image/getImageProps.ts | 185 ++++++++++++------ 1 file changed, 125 insertions(+), 60 deletions(-) diff --git a/javascript-modules-library/src/utils/image/getImageProps.ts b/javascript-modules-library/src/utils/image/getImageProps.ts index 4149d48b..50db9301 100644 --- a/javascript-modules-library/src/utils/image/getImageProps.ts +++ b/javascript-modules-library/src/utils/image/getImageProps.ts @@ -1,6 +1,15 @@ 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 { + buildImageUrl, + commaSafe, + type ImageResizeChannel, + type ImageUrlOptions, +} from "./buildImageUrl.js"; +import { + resolveImageDefaults, + type ImageContext, + type ImageSourceOptions, +} from "./imageDefaults.js"; import { clampToIntrinsic, readImageMeta } from "./imageMeta.js"; import { warnIgnoredResize } from "./warnIgnoredResize.js"; @@ -8,18 +17,22 @@ import { warnIgnoredResize } from "./warnIgnoredResize.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 + * - `constrained` (default): the image is at most `slotWidth` 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). + * - `fixed`: the image is always `slotWidth` 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). + * - `fill`: the image fills its closest positioned ancestor, whose size the markup does not know. No + * `slotWidth`, and `sizes` is required because nothing else can describe the box. This is the + * layout for a slot sized in `%`, `fr`, `rem` or by an aspect-ratio container — which, on a fluid + * design, is most of them. */ -export type ImageLayout = "constrained" | "fixed" | "full-width"; +export type ImageLayout = "constrained" | "fixed" | "full-width" | "fill"; /** * 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, @@ -41,6 +54,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,7 +66,7 @@ export interface ImageProps { alt: string; } -export interface ImageOptions { +export interface ImageOptions extends ImageSourceOptions { /** Alternative text; `""` declares the image decorative. */ alt: string; /** @@ -57,13 +75,24 @@ export interface ImageOptions { * @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`. */ + /** + * The slot width in CSS pixels. Required by `constrained` and `fixed`, meaningless for + * `full-width` and `fill`. + * + * Named apart from the `width` HTML attribute on purpose: this is how much room the layout gives + * the image, not a number that ends up in the markup. + */ + slotWidth?: number; + /** Explicit candidate widths in image pixels. Overrides the ladder the layout would derive. */ widths?: number[]; - /** Explicit `sizes` attribute. Escape hatch: prefer `layout` + `width`. */ + /** + * Explicit `sizes` attribute. Required by the `fill` layout. + * + * `"auto"` lets the browser measure the real box, which beats any value derivable from the markup + * — and it forces `loading="lazy"`, the only mode in which browsers read it. + */ sizes?: string; - /** Candidate ladder used by `constrained` and `full-width`. */ + /** Candidate ladder used by `constrained`, `full-width` and `fill`. */ breakpoints?: readonly number[]; /** * Register a render cache dependency on the image node, so that editing the image flushes the @@ -75,47 +104,57 @@ export interface ImageOptions { } /** - * 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. + * True for a `sizes` value whose first entry is `auto`. * - * @see {@link https://github.com/Jahia/jahia/issues/23} + * 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} */ -const srcSetSafe = (url: string) => url.replaceAll(",", "%2C"); +export const isAutoSizes = (sizes: string | undefined): boolean => + sizes !== undefined && sizes.trim().split(",")[0].trim().toLowerCase() === "auto"; /** The candidate widths a layout asks for, before clamping. */ const candidateWidths = ( layout: ImageLayout, - width: number | undefined, + slotWidth: number | undefined, breakpoints: readonly number[], ): number[] => { - if (layout === "full-width") return [...breakpoints]; + // The slot is the viewport, or a box the markup cannot measure: offer the whole ladder + if (layout === "full-width" || layout === "fill") return [...breakpoints]; - if (width === undefined) { + if (slotWidth === 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.`, + `getImageProps: layout "${layout}" needs a slotWidth (the slot width in CSS pixels). ` + + `Use layout "fill" when the slot is sized by CSS the markup cannot read, or ` + + `"full-width" for an image that always spans the viewport.`, ); } // Two device-pixel ratios cover the realistic range; a 3x file is rarely worth its bytes - const densities = [width, width * 2]; + const densities = [slotWidth, slotWidth * 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]; + return [...breakpoints.filter((candidate) => candidate < slotWidth), ...densities]; }; /** The `sizes` attribute a layout implies. */ -const derivedSizes = (layout: ImageLayout, width: number | undefined): string => { +const derivedSizes = (layout: ImageLayout, slotWidth: number | undefined): string => { switch (layout) { case "full-width": return "100vw"; case "fixed": - return `${width}px`; + return `${slotWidth}px`; case "constrained": - return `(min-width: ${width}px) ${width}px, 100vw`; + return `(min-width: ${slotWidth}px) ${slotWidth}px, 100vw`; + case "fill": + throw new Error( + 'getImageProps: layout "fill" needs an explicit sizes, because the image is sized by its ' + + "parent and nothing in the markup says how wide that is. " + + 'Use sizes="auto" to let the browser measure the real box (it loads the image lazily), ' + + 'or describe the slot, as in sizes="(min-width: 60rem) 33vw, 100vw".', + ); } }; @@ -123,12 +162,14 @@ const derivedSizes = (layout: ImageLayout, width: number | undefined): string => * 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. + * Declare how the image sits in the page with `layout` + `slotWidth` and the candidates and `sizes` + * are derived; on a fluid layout, where no slot has a width in CSS pixels, use `layout="fill"` with + * a `sizes` of your own — that is the normal case, not the escape hatch. * * @example * ```tsx - * + * + * * ```; * * @param node - The file node holding the image. @@ -140,36 +181,50 @@ const derivedSizes = (layout: ImageLayout, width: number | undefined): string => export function getImageProps( node: JCRNodeWrapper, options: ImageOptions, - context?: { renderContext?: RenderContext; currentResource?: Resource }, + context?: ImageContext, ): ImageProps { - const { - alt, - layout = "constrained", - width, - widths, - sizes, - breakpoints = DEFAULT_BREAKPOINTS, - cacheDependency = true, - } = options; + const { alt, layout = "constrained", slotWidth, widths, sizes, cacheDependency = true } = options; 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 }; + /** What the caller asked for, once the layout has had its say. */ + const resolveSizes = (): string | undefined => + layout === "fill" ? (sizes ?? derivedSizes(layout, slotWidth)) : sizes; + + const withLoading = (props: ImageProps): ImageProps => + 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: resolveSizes(), + }); } - const requested = (widths ?? candidateWidths(layout, width, breakpoints)) + const requested = (widths ?? candidateWidths(layout, slotWidth, breakpoints)) .filter((candidate) => candidate > 0) .map((candidate) => clampToIntrinsic(candidate, meta.intrinsicWidth)) .sort((a, b) => a - b); @@ -187,7 +242,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 +250,17 @@ 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 ? (resolveSizes() ?? derivedSizes(layout, slotWidth)) : resolveSizes(), + }); } /** @@ -212,11 +269,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; } From ee41c28e49250ac07adaf8419d5806b0acf34ddd Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 20:09:28 +0200 Subject: [PATCH 05/10] feat(library): JImage never swallows an attribute the caller needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jahia/jahia.com's card icons take their box from the `width`/`height` HTML attributes and have no CSS rule at all, so they could not use the component: it dropped `height` for good, and hand-listed what it omitted from `ImgHTMLAttributes`. The omit is now derived from the component's own props, so a prop added later cannot silently eat an attribute — only `src` and `srcSet`, which the component computes, are additionally withheld. `width` and `height` are re-declared as the HTML attributes and win over the intrinsic pair; writing one stops the other being emitted, because half of each would state a wrong aspect ratio. `attributes` is the open map, spread last, taking a record or a function of the resolved image — the form an analytics attribute derived from the final `src` needs, and the way `data-*` reaches the element at all, since React's typings do not model it. `priority` becomes `preload`, following `next/image` 16, which renamed it and deprecated the old spelling. Nothing is released yet, so there is no alias to carry. Also lands `layout="fill"`'s positioning style and `placeholder="blur"`, both opt-in and both styling the component would otherwise refuse to emit. Part of Jahia/javascript-modules#762, Jahia/javascript-modules#761 and Jahia/javascript-modules#764. --- .../src/components/JImage.spec.tsx | 254 ++++++++++++++++++ .../src/components/JImage.tsx | 243 +++++++++++++---- javascript-modules-library/src/index.ts | 14 +- 3 files changed, 458 insertions(+), 53 deletions(-) create mode 100644 javascript-modules-library/src/components/JImage.spec.tsx 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..2247bf2c --- /dev/null +++ b/javascript-modules-library/src/components/JImage.spec.tsx @@ -0,0 +1,254 @@ +import { 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("stops mixing its dimensions with the caller's, which would state a wrong ratio", () => { + const props = attributesOf(JImage({ node: imageNode(), alt: "", slotWidth: 600, width: 48 })); + expect(props.width).toBe(48); + expect(props.height).toBeUndefined(); + // Nothing reserves the space any more, so lazy loading would shift the layout + expect(props.loading).toBeUndefined(); + }); +}); + +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("refuses to be preloaded rather than silently downloading the largest candidate", () => { + expect(() => + JImage({ node: imageNode(), alt: "", layout: "fill", sizes: "auto", preload: true }), + ).toThrow(/cannot be combined with preload/); + }); + + it('refuses an explicit loading="eager" for the same reason', () => { + expect(() => + JImage({ node: imageNode(), alt: "", layout: "fill", sizes: "auto", loading: "eager" }), + ).toThrow(/loading="eager"/); + }); +}); + +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 missing node", () => { + it("renders the module asset offered as a fallback", () => { + const props = attributesOf(JImage({ alt: "Nothing yet", 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: "" })).toBeNull(); + }); +}); diff --git a/javascript-modules-library/src/components/JImage.tsx b/javascript-modules-library/src/components/JImage.tsx index 4c96ae52..b2745da6 100644 --- a/javascript-modules-library/src/components/JImage.tsx +++ b/javascript-modules-library/src/components/JImage.tsx @@ -1,23 +1,124 @@ -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 { buildThumbnailUrl, cssUrl } from "../utils/image/buildImageUrl.js"; +import { + getImageProps, + isAutoSizes, + type ImageLayout, + type ImageProps, +} from "../utils/image/getImageProps.js"; +import type { ImageSourceOptions } from "../utils/image/imageDefaults.js"; import { buildModuleFileUrl } from "../utils/urlBuilder/urlBuilder.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 ImageAttributes = + | Record + | ((image: ImageProps) => Record); + +/** + * 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 JImageProps 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; + /** + * How the image occupies its slot. + * + * @default "constrained" + */ + layout?: ImageLayout; + /** The slot width in CSS pixels. Required by the `constrained` and `fixed` layouts. */ + slotWidth?: number; + /** Explicit candidate widths in image pixels. Overrides the ladder the layout would derive. */ + widths?: number[]; + /** + * Explicit `sizes` attribute. Required by the `fill` layout. `"auto"` measures the real box and + * forces `loading="lazy"`, the only mode in which browsers read it. + */ + sizes?: string; + /** Candidate ladder used by `constrained`, `full-width` and `fill`. */ + 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?: ImageAttributes; + /** + * 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. + * + * The intrinsic pair is emitted only when neither is given: mixing one of yours with one of ours + * would state a wrong aspect ratio. + */ + width?: number | `${number}`; + /** The `height` HTML attribute. Overrides the intrinsic height; see {@link JImageProps.width}. */ + height?: number | `${number}`; +} + +/** 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`. + * Declare how the image sits in the page — `layout`, plus `slotWidth` for the layouts measured in + * CSS pixels — rather than computing candidate widths by hand. On a fluid design, where no slot has + * a pixel width, that is `layout="fill"` with `sizes="auto"`. + * + * 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 - * {title} - * {title} + * + * + * + * * ```; * * @returns The `` element. @@ -26,72 +127,110 @@ 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"> + JImageProps & Omit, "src" | "srcSet" | keyof JImageProps> >): JSX.Element | null { const context = useServerContext(); - const props = node - ? getImageProps(node, { alt, layout, width, widths, sizes }, context) + const image: ImageProps | null = node + ? getImageProps( + node, + { + alt, + layout, + slotWidth, + widths, + sizes, + breakpoints, + cacheDependency, + loader, + quality, + unoptimized, + absolute, + }, + context, + ) : fallback ? { src: buildModuleFileUrl(fallback, {}, context), alt: alt.trim() } : null; - if (!props) return null; + if (!image) return null; + + // The caller owns the box or the library does; a mix of the two states a wrong aspect ratio + const markupSized = width !== undefined || height !== undefined; + const renderedWidth = markupSized ? width : image.width; + const renderedHeight = markupSized ? height : image.height; + + // `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 opposite of what the caller asked for. + const autoSizes = isAutoSizes(image.sizes); + if (autoSizes && (preload || loading === "eager")) { + throw new Error( + 'JImage: sizes="auto" cannot be combined with ' + + (preload ? "preload" : 'loading="eager"') + + ", because browsers only read it on a lazily loaded image and would fall back to 100vw. " + + "Drop one of the two: describe the slot with a media query list to keep it eager, or let " + + "the image load lazily.", + ); + } + + // 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..a4214c66 100644 --- a/javascript-modules-library/src/index.ts +++ b/javascript-modules-library/src/index.ts @@ -9,7 +9,7 @@ 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 ImageAttributes, type JImageProps } from "./components/JImage.js"; // Declaration and registration export { jahiaComponent } from "./framework/jahiaComponent.js"; @@ -29,14 +29,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, @@ -46,6 +50,14 @@ export { type ImageOptions, type ImageProps, } 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 From 55215a882f60f7d0b0ac01a57d97188aa6bd711b Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 20:09:39 +0200 Subject: [PATCH 06/10] test(library): pin the new image semantics, and type-check the specs 94 tests over the fill layout, `sizes="auto"`, attribute pass-through, the loader and its module-wide defaults, quality routing, absolute URLs, background URLs and the cache dependency. The build excludes `*.spec.ts` so they never reach `dist`, which also meant they were never type-checked. `tsconfig.spec.json` gives them their own pass. --- .../src/utils/image/image.spec.ts | 448 ++++++++++++++++-- javascript-modules-library/tsconfig.spec.json | 8 + 2 files changed, 429 insertions(+), 27 deletions(-) create mode 100644 javascript-modules-library/tsconfig.spec.json diff --git a/javascript-modules-library/src/utils/image/image.spec.ts b/javascript-modules-library/src/utils/image/image.spec.ts index 4ec088d6..c60658e0 100644 --- a/javascript-modules-library/src/utils/image/image.spec.ts +++ b/javascript-modules-library/src/utils/image/image.spec.ts @@ -5,22 +5,37 @@ import type { JCRNodeWrapper } from "org.jahia.services.content"; // 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}`)); - } - - return config?.parameters ? `${node.url}?${new URLSearchParams(config.parameters)}` : node.url; - }, - buildModuleFileUrl: (path: string) => `/modules/test${path}`, -})); +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 { + 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; + + return toAbsoluteUrl(url, node as never, config?.absolute); + }, + buildModuleFileUrl: (path: string) => `/modules/test${path}`, + }; +}); -const { buildImageUrl } = await import("./buildImageUrl.js"); -const { getImageProps, DEFAULT_BREAKPOINTS } = await import("./getImageProps.js"); +const { buildImageUrl, buildBackgroundImageUrl, buildThumbnailUrl } = + await import("./buildImageUrl.js"); +const { setImageDefaults, clearImageDefaults } = await import("./imageDefaults.js"); +const { getImageProps, inspectImageChannel, DEFAULT_BREAKPOINTS } = + await import("./getImageProps.js"); const { readImageMeta } = await import("./imageMeta.js"); /** A JCR file node holding an image, with just the surface the image code touches. */ @@ -32,6 +47,7 @@ const imageNode = ({ height, defaultProvider = true, thumbnails = ["thumbnail", "thumbnail2"], + serverName = "www.example.com", getUrl, }: { url?: string; @@ -41,6 +57,7 @@ const imageNode = ({ height?: number; defaultProvider?: boolean; thumbnails?: string[]; + serverName?: string; getUrl?: (params: string[]) => string; } = {}) => ({ @@ -51,6 +68,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) => { @@ -155,7 +173,7 @@ describe("buildImageUrl", () => { describe("getImageProps", () => { it("requires a width for a constrained layout, and says why", () => { expect(() => getImageProps(imageNode({ width: 2000 }), { alt: "" })).toThrow( - /layout "constrained" needs a width/, + /layout "constrained" needs a slotWidth/, ); }); @@ -163,7 +181,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,7 +192,7 @@ 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, }); expect(props.srcSet).toBe( "/files/photo.jpg?w=320 320w, /files/photo.jpg?w=640 640w, " + @@ -197,11 +215,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 +237,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 +257,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 +268,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,7 +282,7 @@ 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"); @@ -281,10 +301,10 @@ 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(""); }); }); @@ -307,7 +327,7 @@ describe("the ignored-resize warning", () => { }); /** 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(); @@ -390,3 +410,377 @@ describe("the ignored-resize warning", () => { expect(warn).toHaveBeenCalledTimes(1); }); }); + +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", () => { + expect(() => getImageProps(imageNode({ width: 4000 }), { alt: "", layout: "fill" })).toThrow( + /layout "fill" needs an explicit sizes/, + ); + }); + + 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/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"] +} From fd80998fe468058051915818239147853c2d5748 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 20:09:39 +0200 Subject: [PATCH 07/10] docs(images): lead with the fluid slot, which is the ordinary case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide presented `widths`/`sizes` as the exception and `layout` + a slot width as the norm. On a real site it is the other way round, so the layout table now starts from the question that decides it — whether anything in your markup knows how wide the image is — and names the layout each kind of slot wants. Adds the sections the new surface needs: attribute pass-through, the `attributes` map, `placeholder`, loaders and module defaults, background images, absolute URLs. Renames the slot width to `slotWidth` in the tutorial and the hydrogen sample, and `priority` to `preload` throughout. Part of Jahia/javascript-modules#761. --- .chachalog/img7Kq2Ls.md | 2 +- .chachalog/img9Fv3Rt.md | 10 + .../4-making-a-blog/README.md | 2 +- docs/2-guides/8-images/README.md | 178 ++++++++++++++---- .../components/BlogPost/default.server.tsx | 2 +- 5 files changed, 155 insertions(+), 39 deletions(-) create mode 100644 .chachalog/img9Fv3Rt.md 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..614e1eb2 --- /dev/null +++ b/.chachalog/img9Fv3Rt.md @@ -0,0 +1,10 @@ +--- +# Allowed version bumps: patch, minor, major +javascript-modules: minor +--- + +Made the image API usable on a fluid site: a `fill` layout, first-class `sizes="auto"`, full `` attribute pass-through, a pluggable loader, and CSS background and absolute URLs. (#766) + +`` covers the slot whose width no view can know — a percentage, a grid cell, an aspect-ratio box — which on a fluid design is most of them. The component now forwards every `` attribute it does not compute itself, including `width` and `height`, so an image can take its box from the markup with no CSS rule at all, 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, `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, and the image that loads first is marked `preload` rather than `priority`, following `next/image` 16. 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 (
- +

{title}

diff --git a/docs/2-guides/8-images/README.md b/docs/2-guides/8-images/README.md index b8ccfe82..d4d11e7d 100644 --- a/docs/2-guides/8-images/README.md +++ b/docs/2-guides/8-images/README.md @@ -20,91 +20,171 @@ jahiaComponent( ({ 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, `loading="lazy"`, and a registered cache dependency on the image node. `fill` stretches the image over `.cover`'s nearest positioned ancestor, so give that element a `position: relative` and a height. -## Declare the layout, not the numbers +## Pick the layout your slot actually has -The one number you provide is `width`: how wide the image's slot is, in CSS pixels. How that slot behaves is the `layout`: +The first question is not how wide the image is. It is **whether anything in your markup knows how wide it is.** -| `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 | +| Your slot | Layout | You also provide | +| ----------------------------------------------------------------------------------------------- | -------------------------- | -------------------------- | +| sized by CSS you cannot read from the view — `%`, `fr`, `rem`, a grid cell, an aspect-ratio box | `fill` | `sizes` (usually `"auto"`) | +| spans the viewport — a hero, a full-bleed banner | `full-width` | nothing | +| a real number of CSS pixels, always — an avatar, a fixed logo slot | `fixed` | `slotWidth` | +| at most a number of CSS pixels, shrinking on a narrow viewport | `constrained` (default) | `slotWidth` | +| the box comes from the `width`/`height` attributes, with no CSS at all | any, with `width`+`height` | `width`, `height` | + +On a fluid design, most slots are the first row. `fill` is the ordinary case, not an escape hatch: ```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. + +### `fill` and `sizes` + +`fill` stretches the image over its **closest positioned ancestor** — give that parent `position: relative` (and a height, or an `aspect-ratio`). It is the one layout that carries CSS of its own, because "fills its parent" is not something markup can say; your own `style` still wins over it. + +Since nothing in the document says how wide that parent is, `sizes` is required. `sizes="auto"` is usually the right answer: the browser measures the real box after layout, which beats any value you could derive. Two things follow from the specification: -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. +- **`auto` only works on a lazily loaded image**, so `` sets `loading="lazy"` for you. +- **`auto` and `preload` are contradictory.** Combining them throws, rather than letting the browser quietly fall back to `100vw` and download the largest candidate on every screen. For an above-the-fold `fill` image, describe the slot instead: `sizes="(min-width: 60rem) 33vw, 100vw"`. ### Two different widths: the slot and the file -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. +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`, `2 × slotWidth` | no — the slot is one number, so two files cover it | +| `constrained` | ladder entries below `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 | +| `fill` | the whole ladder | yes — the slot is unknown at build time, so offer everything | -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. +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 × 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. +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. -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. ### 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. -## Above the fold: `priority` +## 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. + +## Every `` attribute still reaches the element + +`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 come as a pair: as soon as you write one, the library stops emitting the other from the image's intrinsic size, because half of yours and half of ours would state a wrong aspect ratio. + +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 is required `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 + +`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 + + +``` + +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. + ## What actually resizes the image, and where 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. | 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, and any request that matches the original size | Nothing to do | +| `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 or request a thumbnail width. +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. -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. +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. + +### 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: + +```tsx +const cloudinary = ({ src, width, quality }) => + `https://res.cloudinary.com/acme/image/fetch/f_auto,q_${quality ?? "auto"},w_${width}/${src}`; + +; +``` + +`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 @@ -114,7 +194,7 @@ An island's props are serialized, so a React element cannot be one of them, and // gallery.server.tsx import { getImageProps, Island } from "@jahia/javascript-modules-library"; -const images = photos.map((photo) => getImageProps(photo, { alt: title, width: 800 })); +const images = photos.map((photo) => getImageProps(photo, { alt: title, slotWidth: 800 })); ; ``` @@ -129,11 +209,33 @@ export default function Gallery({ images }: { images: ImageProps[] }) { } ``` -`ImageProps` is plain, serializable data, and `alt` is required there too. +`ImageProps` is plain, serializable data, and `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. + +## 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, layout: "fill", sizes: "auto", absolute: true }); +``` + +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 +243,11 @@ server.render.addCacheDependency({ node: imageNode }, renderContext); ## Reference -- `JImage` — the component; renders an unstyled ``, so pass a `className`. Server-side only. +- `JImage` — the component. Renders an unstyled ``, except where the feature is styling (`fill`, `placeholder`). 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. +- `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/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 (
- +

{title}

From 92f253726cebb7949c2b4d3aeec133c8bca73c7b Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 21:12:41 +0200 Subject: [PATCH 08/10] feat(library)!: name the fluid slot, and make the image API's edges loud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five things the adversarial DX review in #767 found, after the API was adopted on three real codebases. `layout="fluid"` names the slot the markup cannot measure but the image still sits in: a percentage, a grid cell, an `aspect-ratio` box, a slot constrained by its height. That is the shape `fill` was standing in for, and `fill` positions the image over a parent — which 0 of 16 luxe sites and 9 of 13 fluid jahia.com sites could use. `fill` now means only what it says; both layouts draw the same candidates, so only the positioning and the intrinsic pair differ. `widths` without a `slotWidth` emitted `sizes="(min-width: undefinedpx) undefinedpx, 100vw"`, which browsers discard before fetching the largest candidate on every screen — the exact failure the API exists to prevent. The guard `candidateWidths` already had is now shared with `derivedSizes`, which is what the explicit-`widths` path reaches instead. `width` and `height` are required together, so a call site written against the previous API — where `width` was the slot — fails where it is written instead of type-checking and throwing on first render. `getImageProps` takes a `fallback` and requires its context. Both of its real users reached past it and hand-built a bare `{ src, alt }` with no srcSet, no dimensions and no cache dependency, and an omitted context silently dropped both the cache dependency and `setImageDefaults`. `ImageProps` becomes `ImgProps` — the data that comes out, as opposed to `JImageProps`, the component's own props — which is what `next/image` means by `ImageProps` and what the link API next door names `AnchorProps`. `ImageAttributes` becomes `ExtraImageAttributes`. Two runtime edges stop being fatal or silent. `sizes="auto"` with `preload` no longer throws: those props legitimately arrive from different layers, a shared wrapper cannot express the exclusion, and a 500 on a production page is worse than a wasteful `sizes` — so the eager load wins, the layout's own `sizes` replaces `auto`, and a development instance says so. And a raster node with no `j:width`, which silently loses both its CLS reservation and `loading="lazy"`, now warns once. Part of Jahia/javascript-modules#767. --- .../src/components/JImage.spec.tsx | 113 +++++++++++-- .../src/components/JImage.tsx | 116 +++++++------ javascript-modules-library/src/index.ts | 9 +- .../src/utils/image/devWarnings.ts | 120 ++++++++++++++ .../src/utils/image/getImageProps.ts | 155 +++++++++++++----- .../src/utils/image/image.spec.ts | 132 ++++++++++++++- .../src/utils/image/warnIgnoredResize.ts | 56 ------- 7 files changed, 532 insertions(+), 169 deletions(-) create mode 100644 javascript-modules-library/src/utils/image/devWarnings.ts delete mode 100644 javascript-modules-library/src/utils/image/warnIgnoredResize.ts diff --git a/javascript-modules-library/src/components/JImage.spec.tsx b/javascript-modules-library/src/components/JImage.spec.tsx index 2247bf2c..14965737 100644 --- a/javascript-modules-library/src/components/JImage.spec.tsx +++ b/javascript-modules-library/src/components/JImage.spec.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +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 @@ -83,12 +83,19 @@ describe("attribute pass-through", () => { expect(props).toMatchObject({ width: 4000, height: 2000, loading: "lazy" }); }); - it("stops mixing its dimensions with the caller's, which would state a wrong ratio", () => { - const props = attributesOf(JImage({ node: imageNode(), alt: "", slotWidth: 600, width: 48 })); - expect(props.width).toBe(48); - expect(props.height).toBeUndefined(); - // Nothing reserves the space any more, so lazy loading would shift the layout - expect(props.loading).toBeUndefined(); + 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" }); }); }); @@ -145,16 +152,81 @@ describe('sizes="auto"', () => { expect(props).toMatchObject({ sizes: "auto", loading: "lazy" }); }); - it("refuses to be preloaded rather than silently downloading the largest candidate", () => { - expect(() => - JImage({ node: imageNode(), alt: "", layout: "fill", sizes: "auto", preload: true }), - ).toThrow(/cannot be combined with preload/); + it("gives way to preload, which a shared wrapper's default cannot argue with", () => { + const props = attributesOf( + JImage({ node: imageNode(), alt: "", layout: "fluid", sizes: "auto", preload: true }), + ); + // Nothing in the markup describes a fluid slot, so the safe answer is all that is left + expect(props.sizes).toBe("100vw"); + expect(props).toMatchObject({ loading: "eager", fetchPriority: "high" }); + }); + + it("falls back to the sizes the layout derives, not to the browser default", () => { + const props = attributesOf( + JImage({ node: imageNode(), alt: "", slotWidth: 600, sizes: "auto", 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: () => {} } }); }); - it('refuses an explicit loading="eager" for the same reason', () => { - expect(() => - JImage({ node: imageNode(), alt: "", layout: "fill", sizes: "auto", loading: "eager" }), - ).toThrow(/loading="eager"/); + /** 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: "", layout: "fluid", 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: "", layout: "fluid", 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: "", layout: "fluid", sizes: "auto" }); + freshJImage({ node: imageNode(), alt: "", layout: "full-width", preload: true }); + + expect(autoSizesMessages(warn)).toHaveLength(0); }); }); @@ -242,6 +314,17 @@ describe("placeholder", () => { }); }); +describe('the "fluid" layout', () => { + it("carries no CSS of its own: it is an ordinary image in the normal flow", () => { + const props = attributesOf( + JImage({ node: imageNode(), alt: "", layout: "fluid", 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" }); + }); +}); + describe("a missing node", () => { it("renders the module asset offered as a fallback", () => { const props = attributesOf(JImage({ alt: "Nothing yet", fallback: "img/placeholder.jpg" })); diff --git a/javascript-modules-library/src/components/JImage.tsx b/javascript-modules-library/src/components/JImage.tsx index b2745da6..aa25b3e9 100644 --- a/javascript-modules-library/src/components/JImage.tsx +++ b/javascript-modules-library/src/components/JImage.tsx @@ -2,14 +2,15 @@ import type { CSSProperties, ImgHTMLAttributes, JSX } from "react"; import type { JCRNodeWrapper } from "org.jahia.services.content"; import { useServerContext } from "../hooks/useServerContext.js"; import { buildThumbnailUrl, cssUrl } from "../utils/image/buildImageUrl.js"; +import { warnAutoSizesEager } from "../utils/image/devWarnings.js"; import { getImageProps, isAutoSizes, + layoutNeedsSizes, type ImageLayout, - type ImageProps, + type ImgProps, } from "../utils/image/getImageProps.js"; import type { ImageSourceOptions } from "../utils/image/imageDefaults.js"; -import { buildModuleFileUrl } from "../utils/urlBuilder/urlBuilder.js"; /** * Attributes spread onto the `` last, after everything the component computed. @@ -18,9 +19,18 @@ import { buildModuleFileUrl } from "../utils/urlBuilder/urlBuilder.js"; * 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 ImageAttributes = +export type ExtraImageAttributes = | Record - | ((image: ImageProps) => 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. @@ -45,11 +55,11 @@ export interface JImageProps extends ImageSourceOptions { /** Explicit candidate widths in image pixels. Overrides the ladder the layout would derive. */ widths?: number[]; /** - * Explicit `sizes` attribute. Required by the `fill` layout. `"auto"` measures the real box and - * forces `loading="lazy"`, the only mode in which browsers read it. + * Explicit `sizes` attribute. Required by the `fluid` and `fill` layouts. `"auto"` measures the + * real box and forces `loading="lazy"`, the only mode in which browsers read it. */ sizes?: string; - /** Candidate ladder used by `constrained`, `full-width` and `fill`. */ + /** Candidate ladder used by every layout but `fixed`. */ breakpoints?: readonly number[]; /** * Register a render cache dependency on the image node. @@ -77,13 +87,14 @@ export interface JImageProps extends ImageSourceOptions { /** The `placeholder="blur"` source, when the Jahia thumbnail is not the one you want. */ blurDataURL?: string; /** Spread onto the `` last. */ - attributes?: ImageAttributes; + 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. * - * The intrinsic pair is emitted only when neither is given: mixing one of yours with one of ours - * would state a wrong aspect ratio. + * 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 {@link JImageProps.slotWidth}, a different + * number with a different job. */ width?: number | `${number}`; /** The `height` HTML attribute. Overrides the intrinsic height; see {@link JImageProps.width}. */ @@ -104,7 +115,7 @@ const FILL_STYLE: CSSProperties = { * * Declare how the image sits in the page — `layout`, plus `slotWidth` for the layouts measured in * CSS pixels — rather than computing candidate widths by hand. On a fluid design, where no slot has - * a pixel width, that is `layout="fill"` with `sizes="auto"`. + * a pixel width, that is `layout="fluid"` with `sizes="auto"`. * * 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 @@ -115,9 +126,9 @@ const FILL_STYLE: CSSProperties = { * * @example * ```tsx + * * * - * * * ```; * @@ -149,51 +160,62 @@ export function JImage({ ...imgAttributes }: Readonly< JImageProps & Omit, "src" | "srcSet" | keyof JImageProps> ->): JSX.Element | null { +> & + MarkupBox): JSX.Element | null { const context = useServerContext(); - const image: ImageProps | null = node - ? getImageProps( - node, - { - alt, - layout, - slotWidth, - widths, - sizes, - breakpoints, - cacheDependency, - loader, - quality, - unoptimized, - absolute, - }, - 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 the layout's own `sizes` replaces `auto`. + const eagerness = preload ? "preload" : loading === "eager" ? 'loading="eager"' : undefined; + const autoOverridden = eagerness !== undefined && isAutoSizes(sizes); + const requestedSizes = !autoOverridden + ? sizes + : // A layout that derives no `sizes` of its own has only the safe, wasteful answer left + layoutNeedsSizes(layout) + ? "100vw" + : undefined; + + const image: ImgProps | null = getImageProps( + node, + { + alt, + layout, + slotWidth, + widths, + sizes: requestedSizes, + breakpoints, + cacheDependency, + loader, + quality, + unoptimized, + absolute, + fallback, + }, + 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 || height !== undefined; + const markupSized = width !== undefined; const renderedWidth = markupSized ? width : image.width; const renderedHeight = markupSized ? height : image.height; - // `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 opposite of what the caller asked for. const autoSizes = isAutoSizes(image.sizes); - if (autoSizes && (preload || loading === "eager")) { - throw new Error( - 'JImage: sizes="auto" cannot be combined with ' + - (preload ? "preload" : 'loading="eager"') + - ", because browsers only read it on a lazily loaded image and would fall back to 100vw. " + - "Drop one of the two: describe the slot with a media query list to keep it eager, or let " + - "the image load lazily.", - ); - } // 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 diff --git a/javascript-modules-library/src/index.ts b/javascript-modules-library/src/index.ts index a4214c66..5533a1c9 100644 --- a/javascript-modules-library/src/index.ts +++ b/javascript-modules-library/src/index.ts @@ -9,7 +9,12 @@ 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, type ImageAttributes, type JImageProps } from "./components/JImage.js"; +export { + JImage, + type ExtraImageAttributes, + type JImageProps, + type MarkupBox, +} from "./components/JImage.js"; // Declaration and registration export { jahiaComponent } from "./framework/jahiaComponent.js"; @@ -48,7 +53,7 @@ export { DEFAULT_BREAKPOINTS, type ImageLayout, type ImageOptions, - type ImageProps, + type ImgProps, } from "./utils/image/getImageProps.js"; export { setImageDefaults, 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 50db9301..627fbbe8 100644 --- a/javascript-modules-library/src/utils/image/getImageProps.ts +++ b/javascript-modules-library/src/utils/image/getImageProps.ts @@ -1,38 +1,45 @@ import type { JCRNodeWrapper } from "org.jahia.services.content"; +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"; /** * 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. * + * 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): the image is at most `slotWidth` CSS pixels wide and shrinks with the * viewport below that — the common case for content in a column. * - `fixed`: the image is always `slotWidth` 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). - * - `fill`: the image fills its closest positioned ancestor, whose size the markup does not know. No - * `slotWidth`, and `sizes` is required because nothing else can describe the box. This is the - * layout for a slot sized in `%`, `fr`, `rem` or by an aspect-ratio container — which, on a fluid - * design, is most of them. + * - `fluid`: a normal-flow slot whose width the markup cannot know — a `%`, a `fr`, a grid cell, an + * `aspect-ratio` box, a slot constrained by its height. No `slotWidth`, and `sizes` is required + * because nothing else can describe the box. On a fluid design this is most slots. + * - `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. Like + * `fluid` 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" | "fill"; +export type ImageLayout = "constrained" | "fixed" | "fluid" | "full-width" | "fill"; /** * Candidate file widths, in image pixels, offered for the layouts where the slot is not a single - * number — `constrained` below its maximum, `full-width` and `fill` always. A `fixed` slot never - * uses them: its width and that width doubled cover it. + * number — `constrained` below its maximum, `fluid`, `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, @@ -40,13 +47,20 @@ export type ImageLayout = "constrained" | "fixed" | "full-width" | "fill"; */ export const DEFAULT_BREAKPOINTS: readonly number[] = [320, 640, 960, 1280, 1920, 2560]; +/** + * The layouts whose slot width nothing in the markup states, so no `sizes` can be derived for them + * and the caller has to supply one. An unset layout is `constrained`, which derives its own. + */ +export const layoutNeedsSizes = (layout: ImageLayout | undefined): boolean => + layout === "fluid" || layout === "fill"; + /** * `` props built from a JCR image node. * * 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; @@ -76,8 +90,8 @@ export interface ImageOptions extends ImageSourceOptions { */ layout?: ImageLayout; /** - * The slot width in CSS pixels. Required by `constrained` and `fixed`, meaningless for - * `full-width` and `fill`. + * The slot width in CSS pixels. Required by `constrained` and `fixed`, meaningless for the + * layouts whose width the markup does not state. * * Named apart from the `width` HTML attribute on purpose: this is how much room the layout gives * the image, not a number that ends up in the markup. @@ -86,13 +100,13 @@ export interface ImageOptions extends ImageSourceOptions { /** Explicit candidate widths in image pixels. Overrides the ladder the layout would derive. */ widths?: number[]; /** - * Explicit `sizes` attribute. Required by the `fill` layout. + * Explicit `sizes` attribute. Required by the `fluid` and `fill` layouts. * * `"auto"` lets the browser measure the real box, which beats any value derivable from the markup * — and it forces `loading="lazy"`, the only mode in which browsers read it. */ sizes?: string; - /** Candidate ladder used by `constrained`, `full-width` and `fill`. */ + /** 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 @@ -101,6 +115,12 @@ export interface ImageOptions extends ImageSourceOptions { * @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; } /** @@ -114,6 +134,19 @@ export interface ImageOptions extends ImageSourceOptions { export const isAutoSizes = (sizes: string | undefined): boolean => sizes !== undefined && sizes.trim().split(",")[0].trim().toLowerCase() === "auto"; +/** The `slotWidth` the layouts measured in CSS pixels cannot work without. */ +const requireSlotWidth = (layout: ImageLayout, slotWidth: number | undefined): number => { + if (slotWidth === undefined) { + throw new Error( + `getImageProps: layout "${layout}" needs a slotWidth (the slot width in CSS pixels). ` + + `Use layout "fluid" when the slot is sized by CSS the markup cannot read, or ` + + `"full-width" for an image that always spans the viewport.`, + ); + } + + return slotWidth; +}; + /** The candidate widths a layout asks for, before clamping. */ const candidateWidths = ( layout: ImageLayout, @@ -121,37 +154,40 @@ const candidateWidths = ( breakpoints: readonly number[], ): number[] => { // The slot is the viewport, or a box the markup cannot measure: offer the whole ladder - if (layout === "full-width" || layout === "fill") return [...breakpoints]; + if (layout === "full-width" || layoutNeedsSizes(layout)) return [...breakpoints]; - if (slotWidth === undefined) { - throw new Error( - `getImageProps: layout "${layout}" needs a slotWidth (the slot width in CSS pixels). ` + - `Use layout "fill" when the slot is sized by CSS the markup cannot read, or ` + - `"full-width" for an image that always spans the viewport.`, - ); - } + const width = requireSlotWidth(layout, slotWidth); // Two device-pixel ratios cover the realistic range; a 3x file is rarely worth its bytes - const densities = [slotWidth, slotWidth * 2]; + 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 < slotWidth), ...densities]; + return [...breakpoints.filter((candidate) => candidate < width), ...densities]; }; -/** The `sizes` attribute a layout implies. */ +/** + * The `sizes` attribute a layout implies. + * + * Reached both from the layouts that derive one and, when `widths` was explicit and no ladder was + * asked for, from a caller who never named a slot — so it validates `slotWidth` itself rather than + * trusting {@link candidateWidths} to have run first. + */ const derivedSizes = (layout: ImageLayout, slotWidth: number | undefined): string => { switch (layout) { case "full-width": return "100vw"; case "fixed": - return `${slotWidth}px`; - case "constrained": - return `(min-width: ${slotWidth}px) ${slotWidth}px, 100vw`; + return `${requireSlotWidth(layout, slotWidth)}px`; + case "constrained": { + const width = requireSlotWidth(layout, slotWidth); + return `(min-width: ${width}px) ${width}px, 100vw`; + } + case "fluid": case "fill": throw new Error( - 'getImageProps: layout "fill" needs an explicit sizes, because the image is sized by its ' + - "parent and nothing in the markup says how wide that is. " + + `getImageProps: layout "${layout}" needs an explicit sizes, because the slot is sized by ` + + "CSS and nothing in the markup says how wide it is. " + 'Use sizes="auto" to let the browser measure the real box (it loads the image lazily), ' + 'or describe the slot, as in sizes="(min-width: 60rem) 33vw, 100vw".', ); @@ -163,27 +199,58 @@ const derivedSizes = (layout: ImageLayout, slotWidth: number | undefined): strin * matching `sizes`, and the intrinsic dimensions. * * Declare how the image sits in the page with `layout` + `slotWidth` and the candidates and `sizes` - * are derived; on a fluid layout, where no slot has a width in CSS pixels, use `layout="fill"` with - * a `sizes` of your own — that is the normal case, not the escape hatch. + * are derived; on a fluid layout, where no slot has a width in CSS pixels, use `layout="fluid"` + * with `sizes="auto"` — that is the normal case, not the escape hatch. * * @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?: ImageContext, -): ImageProps { - const { alt, layout = "constrained", slotWidth, widths, sizes, cacheDependency = true } = options; + 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", + slotWidth, + widths, + sizes, + cacheDependency = true, + fallback, + } = options; + + if (!node) { + return fallback ? { src: buildModuleFileUrl(fallback, {}, context), alt: alt.trim() } : null; + } const meta = readImageMeta(node); const defaults = resolveImageDefaults(options, context); @@ -196,7 +263,7 @@ export function getImageProps( context, cacheDependency: false, }; - if (cacheDependency && context?.renderContext) { + if (cacheDependency && context.renderContext) { server.render.addCacheDependency({ node }, context.renderContext); } @@ -209,9 +276,9 @@ export function getImageProps( /** What the caller asked for, once the layout has had its say. */ const resolveSizes = (): string | undefined => - layout === "fill" ? (sizes ?? derivedSizes(layout, slotWidth)) : sizes; + layoutNeedsSizes(layout) ? (sizes ?? derivedSizes(layout, slotWidth)) : sizes; - const withLoading = (props: ImageProps): ImageProps => + 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 @@ -224,6 +291,8 @@ export function getImageProps( }); } + if (meta.intrinsicWidth === undefined) warnMissingIntrinsicSize(node); + const requested = (widths ?? candidateWidths(layout, slotWidth, breakpoints)) .filter((candidate) => candidate > 0) .map((candidate) => clampToIntrinsic(candidate, meta.intrinsicWidth)) diff --git a/javascript-modules-library/src/utils/image/image.spec.ts b/javascript-modules-library/src/utils/image/image.spec.ts index c60658e0..00ff0ffa 100644 --- a/javascript-modules-library/src/utils/image/image.spec.ts +++ b/javascript-modules-library/src/utils/image/image.spec.ts @@ -1,5 +1,7 @@ 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 @@ -27,15 +29,28 @@ vi.mock("../urlBuilder/urlBuilder.js", async () => { return toAbsoluteUrl(url, node as never, config?.absolute); }, - buildModuleFileUrl: (path: string) => `/modules/test${path}`, + buildModuleFileUrl: (path: string) => `/modules/test/${path}`, }; }); const { buildImageUrl, buildBackgroundImageUrl, buildThumbnailUrl } = await import("./buildImageUrl.js"); const { setImageDefaults, clearImageDefaults } = await import("./imageDefaults.js"); -const { getImageProps, inspectImageChannel, DEFAULT_BREAKPOINTS } = - await import("./getImageProps.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. */ @@ -289,6 +304,18 @@ describe("getImageProps", () => { 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(() => + getImageProps(imageNode({ width: 4000 }), { alt: "", widths: [400, 800] }), + ).toThrow(/layout "constrained" needs a slotWidth/); + expect(() => + 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: "", @@ -308,7 +335,7 @@ describe("getImageProps", () => { }); }); -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", { @@ -320,10 +347,13 @@ 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. */ @@ -409,6 +439,96 @@ 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('the "fluid" layout', () => { + it("draws the whole ladder for a normal-flow slot the markup cannot measure", () => { + const props = getImageProps(imageNode({ width: 4000 }), { + alt: "", + layout: "fluid", + 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: "", + layout: "fluid", + sizes: "auto", + }); + expect(props).toMatchObject({ width: 4000, height: 2000, loading: "lazy" }); + }); + + it("refuses to guess a sizes it cannot derive, and says what to write", () => { + expect(() => getImageProps(imageNode({ width: 4000 }), { alt: "", layout: "fluid" })).toThrow( + /layout "fluid" needs an explicit sizes/, + ); + }); + + it("offers what fill offers, since only the positioning differs", () => { + const node = imageNode({ width: 4000, height: 2000 }); + const fluid = getImageProps(node, { alt: "", layout: "fluid", sizes: "auto" }); + const fill = getImageProps(node, { alt: "", layout: "fill", sizes: "auto" }); + expect(fluid.src).toBe(fill.src); + expect(fluid.srcSet).toBe(fill.srcSet); + expect(fluid.sizes).toBe(fill.sizes); + expect(fluid.loading).toBe(fill.loading); + // The one difference: `fill` takes its box from the parent it is stretched over + expect(fill.width).toBeUndefined(); + }); +}); + +describe("a missing node", () => { + it("renders the module asset offered as a fallback", () => { + expect( + getImagePropsWithContext(null, { alt: "Nothing yet", 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: "" }, {})).toBeNull(); + }); }); describe('the "fill" layout', () => { 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".`, - ); -} From 3dc5cf7fee52f5ab2c2df4a54c3d0b78eb217ebe Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 21:12:51 +0200 Subject: [PATCH 09/10] docs(images): the guide leads with the layout real sites have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide led with `layout="fill"` and routed every fluid slot to it, but `fill` positions the image over its parent: 0 of 16 luxe sites and 9 of 13 fluid jahia.com sites could use it, and each codebase invented its own stand-in. The short version now shows `fluid`, `alt` and `fallback` — the three props a first reader needs, two of which were documented late or not at all — and `fill` gets its own section, with the parent's CSS in the snippet and one consistent statement of which element must be positioned. The layout table names the two slot shapes that had no honest representation, an `aspect-ratio` box and a height-constrained slot, and says why declaring a `slotWidth` the CSS contradicts is worse than declaring none. "What actually resizes the image" moves up to just after that table, because a reader who does not yet know that a plain instance ignores `?w=` concludes their srcSet is broken. It gains a section on `j:width`, which nothing named before. Part of Jahia/javascript-modules#767. --- .chachalog/img9Fv3Rt.md | 6 +- docs/2-guides/8-images/README.md | 160 +++++++++++++++++++++---------- 2 files changed, 112 insertions(+), 54 deletions(-) diff --git a/.chachalog/img9Fv3Rt.md b/.chachalog/img9Fv3Rt.md index 614e1eb2..7dbbac2e 100644 --- a/.chachalog/img9Fv3Rt.md +++ b/.chachalog/img9Fv3Rt.md @@ -3,8 +3,8 @@ javascript-modules: minor --- -Made the image API usable on a fluid site: a `fill` layout, first-class `sizes="auto"`, full `` attribute pass-through, a pluggable loader, and CSS background and absolute URLs. (#766) +Made the image API usable on a fluid site: a `fluid` layout, first-class `sizes="auto"`, full `` attribute pass-through, a pluggable loader, and CSS background and absolute URLs. (#766) -`` covers the slot whose width no view can know — a percentage, a grid cell, an aspect-ratio box — which on a fluid design is most of them. The component now forwards every `` attribute it does not compute itself, including `width` and `height`, so an image can take its box from the markup with no CSS rule at all, 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, `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. +`` covers the slot whose width no view can know — a percentage, a grid cell, an aspect-ratio box, a slot constrained by its height — which on a fluid design is most of them. Its sibling `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, and the image that loads first is marked `preload` rather than `priority`, following `next/image` 16. +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/2-guides/8-images/README.md b/docs/2-guides/8-images/README.md index d4d11e7d..55c31828 100644 --- a/docs/2-guides/8-images/README.md +++ b/docs/2-guides/8-images/README.md @@ -14,54 +14,82 @@ 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 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, `loading="lazy"`, and a registered cache dependency on the image node. `fill` stretches the image over `.cover`'s nearest positioned ancestor, so give that element a `position: relative` and a height. +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. -## Pick the layout your slot actually has +Three props are worth knowing before anything else: + +- **`layout="fluid"`** says the slot is in the normal flow and nothing in the markup knows how wide it is — a `%`, a grid cell, a column that changes at every breakpoint. On a fluid design that is most slots. It goes with a `sizes`, and `sizes="auto"` lets the browser measure the real box. +- **`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. -The first question is not how wide the image is. It is **whether anything in your markup knows how wide it is.** +## Pick the layout your slot actually has -| Your slot | Layout | You also provide | -| ----------------------------------------------------------------------------------------------- | -------------------------- | -------------------------- | -| sized by CSS you cannot read from the view — `%`, `fr`, `rem`, a grid cell, an aspect-ratio box | `fill` | `sizes` (usually `"auto"`) | -| spans the viewport — a hero, a full-bleed banner | `full-width` | nothing | -| a real number of CSS pixels, always — an avatar, a fixed logo slot | `fixed` | `slotWidth` | -| at most a number of CSS pixels, shrinking on a narrow viewport | `constrained` (default) | `slotWidth` | -| the box comes from the `width`/`height` attributes, with no CSS at all | any, with `width`+`height` | `width`, `height` | +The first question is not how wide the image is. It is **whether anything in your markup knows how wide it is** — and, when nothing does, whether the image sits in the normal flow or is stretched over a parent. -On a fluid design, most slots are the first row. `fill` is the ordinary case, not an escape hatch: +| Your slot | Layout | You also provide | +| -------------------------------------------------------------------------------------------------------- | -------------------------- | -------------------------- | +| sized by CSS the view cannot read — `%`, `fr`, `rem`, a grid cell, an `aspect-ratio` box, a fixed height | `fluid` | `sizes` (usually `"auto"`) | +| spans the viewport — a hero, a full-bleed banner | `full-width` | nothing | +| at most a number of CSS pixels, shrinking on a narrow viewport | `constrained` (default) | `slotWidth` | +| a real number of CSS pixels, always — an avatar, a fixed logo slot | `fixed` | `slotWidth` | +| owned by a parent the image is positioned _over_, so it can be cropped under an overlay | `fill` | `sizes`, and parent CSS | +| the box comes from the `width`/`height` attributes, with no CSS at all | any, with `width`+`height` | `width` **and** `height` | ```tsx - + - + ``` `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. -### `fill` and `sizes` +Two slot shapes look like they need a number and do not. An **`aspect-ratio` box** (`.card { aspect-ratio: 16 / 9 }`) and a **height-constrained slot** (`.logo-strip img { height: 3rem; width: auto }`) both state a height, never a width — so their width is whatever the layout gives them, which is exactly `fluid`. Declaring a `slotWidth` your CSS contradicts is worse than declaring none: the browser is then told about a slot that does not exist. + +## 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). -`fill` stretches the image over its **closest positioned ancestor** — give that parent `position: relative` (and a height, or an `aspect-ratio`). It is the one layout that carries CSS of its own, because "fills its parent" is not something markup can say; your own `style` still wins over it. +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. + +### When Jahia never measured the image -Since nothing in the document says how wide that parent is, `sizes` is required. `sizes="auto"` is usually the right answer: the browser measures the real box after layout, which beats any value you could derive. Two things follow from the specification: +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. -- **`auto` only works on a lazily loaded image**, so `` sets `loading="lazy"` for you. -- **`auto` and `preload` are contradictory.** Combining them throws, rather than letting the browser quietly fall back to `100vw` and download the largest candidate on every screen. For an above-the-fold `fill` image, describe the slot instead: `sizes="(min-width: 60rem) 33vw, 100vw"`. +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 +## 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. @@ -74,7 +102,8 @@ The **candidate ladder** is the list of file widths offered for the layouts wher | `fixed` | `slotWidth`, `2 × slotWidth` | no — the slot is one number, so two files cover it | | `constrained` | ladder entries below `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 | -| `fill` | the whole ladder | yes — the slot is unknown at build time, so offer everything | +| `fluid` | the whole ladder | yes — the slot is unknown at build time, so offer everything | +| `fill` | the whole ladder | yes, for the same reason | 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). @@ -82,12 +111,48 @@ Two consequences worth knowing. Candidates stop at `2 × slotWidth`: a 3× file 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. +`widths` (candidate widths, in **image** pixels) overrides the ladder when you know better. It replaces the ladder, not the slot: `constrained` and `fixed` still need their `slotWidth`, 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 (`(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 for `fluid` and `fill`, and one thing follows from the specification: **`auto` is only read on a lazily loaded image**, so `` sets `loading="lazy"` for you. + +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, the layout's own `sizes` replaces `auto`, and a development instance prints one warning naming the image. To choose a better value than the fallback, describe the slot yourself: `sizes="(min-width: 60rem) 33vw, 100vw"`. + +## The `fill` layout: an image positioned over its parent + +`fill` is not the fluid 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`. + +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, you want `fluid` instead. + ## 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). @@ -96,7 +161,7 @@ An image is lazy-loaded by default, which is wrong for the one image that is alr ``` -`preload` 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 @@ -108,7 +173,7 @@ An image is lazy-loaded by default, which is wrong for the one image that is alr ``` -They come as a pair: as soon as you write one, the library stops emitting the other from the image's intrinsic size, because half of yours and half of ours would state a wrong aspect ratio. +**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: @@ -122,7 +187,7 @@ For anything React's typings do not model — `data-*` above all — there is `a /> ``` -## Alternative text is required +## 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: @@ -130,8 +195,6 @@ For anything React's typings do not model — `data-*` above all — there is `a ``` -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 `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. @@ -143,23 +206,9 @@ An image that carries no information of its own — a decorative flourish, or on 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. -## What actually resizes the image, and where - -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. - -| 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 | +`placeholder` covers the loading gap; `fallback` covers the missing node. They are different problems and can be used together. -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. - -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. - -### Your own URL dialect: `loader` +## 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: @@ -192,24 +241,33 @@ 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, slotWidth: 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. 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. +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 @@ -228,7 +286,7 @@ A background has no `srcSet`, so ask for the largest size the slot can reach and ```tsx buildNodeUrl(page, { absolute: true }); buildImageUrl(cover, { width: 1200 }, { absolute: true }).url; -getImageProps(cover, { alt: title, layout: "fill", sizes: "auto", absolute: true }); +getImageProps(cover, { alt: title, layout: "fluid", 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"`. @@ -244,7 +302,7 @@ server.render.addCacheDependency({ node: imageNode }, renderContext); ## Reference - `JImage` — the component. Renders an unstyled ``, except where the feature is styling (`fill`, `placeholder`). Server-side only. -- `getImageProps(node, options)` — the same props as plain data, for islands and for cases where you own the element. +- `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`. From 73976ab2bcd110f91b7b28ff061adb50cf8f306b Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Mon, 24 Aug 2026 00:15:48 +0200 Subject: [PATCH 10/10] feat(library)!: one description of the slot, and the ladder follows it `fluid` is gone: it had zero call sites, and the slot it named is now the ordinary `constrained` one spelled with a `sizes`. `constrained` takes a `slotWidth` xor a `sizes`. Both was two descriptions of one slot, and only the first reached the candidate ladder: three measured luxe sites were served 0.52-0.56x of what their own `sizes` asked for. The ladder is now derived from the `sizes` when there is one, by a parser that reads vw and px terms, decimals, calc(), min(), max() and clamp(), skips the lengths inside media conditions, and falls back to the whole ladder for anything it cannot read. `fixed` rejects a `sizes`, its whole meaning being that the slot is one number. The constrained ladder keeps every breakpoint up to twice the slot width, where it used to stop below the slot width and skip the whole band between W and 2W. Refs #774 --- .chachalog/img9Fv3Rt.md | 6 +- docs/2-guides/8-images/README.md | 106 +++++--- .../src/components/JImage.spec.tsx | 47 +++- .../src/components/JImage.tsx | 61 ++--- javascript-modules-library/src/index.ts | 3 + .../src/utils/image/getImageProps.ts | 248 +++++++++++------- .../src/utils/image/image.spec.ts | 191 ++++++++++++-- .../src/utils/image/sizesLadder.ts | 150 +++++++++++ 8 files changed, 600 insertions(+), 212 deletions(-) create mode 100644 javascript-modules-library/src/utils/image/sizesLadder.ts diff --git a/.chachalog/img9Fv3Rt.md b/.chachalog/img9Fv3Rt.md index 7dbbac2e..6a3730fe 100644 --- a/.chachalog/img9Fv3Rt.md +++ b/.chachalog/img9Fv3Rt.md @@ -3,8 +3,10 @@ javascript-modules: minor --- -Made the image API usable on a fluid site: a `fluid` layout, first-class `sizes="auto"`, full `` attribute pass-through, a pluggable loader, and CSS background and absolute URLs. (#766) +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) -`` covers the slot whose width no view can know — a percentage, a grid cell, an aspect-ratio box, a slot constrained by its height — which on a fluid design is most of them. Its sibling `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. +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/2-guides/8-images/README.md b/docs/2-guides/8-images/README.md index 55c31828..13f0db00 100644 --- a/docs/2-guides/8-images/README.md +++ b/docs/2-guides/8-images/README.md @@ -21,14 +21,7 @@ jahiaComponent( ({ title, cover }: { title: string; cover?: JCRNodeWrapper }) => (

{title}

- +
), ); @@ -36,36 +29,66 @@ jahiaComponent( 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. -Three props are worth knowing before anything else: +Three things are worth knowing before anything else: -- **`layout="fluid"`** says the slot is in the normal flow and nothing in the markup knows how wide it is — a `%`, a grid cell, a column that changes at every breakpoint. On a fluid design that is most slots. It goes with a `sizes`, and `sizes="auto"` lets the browser measure the real box. +- **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. -## Pick the layout your slot actually has +## Pick the spelling your slot actually has -The first question is not how wide the image is. It is **whether anything in your markup knows how wide it is** — and, when nothing does, whether the image sits in the normal flow or is stretched over a parent. +Two questions, in this order. -| Your slot | Layout | You also provide | -| -------------------------------------------------------------------------------------------------------- | -------------------------- | -------------------------- | -| sized by CSS the view cannot read — `%`, `fr`, `rem`, a grid cell, an `aspect-ratio` box, a fixed height | `fluid` | `sizes` (usually `"auto"`) | -| spans the viewport — a hero, a full-bleed banner | `full-width` | nothing | -| at most a number of CSS pixels, shrinking on a narrow viewport | `constrained` (default) | `slotWidth` | -| a real number of CSS pixels, always — an avatar, a fixed logo slot | `fixed` | `slotWidth` | -| owned by a parent the image is positioned _over_, so it can be cropped under an overlay | `fill` | `sizes`, and parent CSS | -| the box comes from the `width`/`height` attributes, with no CSS at all | any, with `width`+`height` | `width` **and** `height` | +**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 - + - - + + + ``` -`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. +`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 -Two slot shapes look like they need a number and do not. An **`aspect-ratio` box** (`.card { aspect-ratio: 16 / 9 }`) and a **height-constrained slot** (`.logo-strip img { height: 3rem; width: auto }`) both state a height, never a width — so their width is whatever the layout gives them, which is exactly `fluid`. Declaring a `slotWidth` your CSS contradicts is worse than declaring none: the browser is then told about a slot that does not exist. +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 @@ -97,21 +120,26 @@ A slot of 400 CSS pixels needs a 400-pixel file on an ordinary screen and an 800 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` | `slotWidth`, `2 × slotWidth` | no — the slot is one number, so two files cover it | -| `constrained` | ladder entries below `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 | -| `fluid` | the whole ladder | yes — the slot is unknown at build time, so offer everything | -| `fill` | the whole ladder | yes, for the same reason | +| 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` + +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. + +`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. + 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: `constrained` and `fixed` still need their `slotWidth`, because that is what `sizes` is built from. +`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 @@ -119,13 +147,13 @@ Candidates are always capped by the original — Jahia never upscales — and th ### `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 for `fluid` and `fill`, and one thing follows from the specification: **`auto` is only read on a lazily loaded image**, so `` sets `loading="lazy"` for you. +`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, the layout's own `sizes` replaces `auto`, and a development instance prints one warning naming the image. To choose a better value than the fallback, describe the slot yourself: `sizes="(min-width: 60rem) 33vw, 100vw"`. +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 fluid 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`. +`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`. It is the one layout that carries CSS of its own, and it only works if the parent cooperates: @@ -151,7 +179,7 @@ It is the one layout that carries CSS of its own, and it only works if the paren 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, you want `fluid` instead. +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` @@ -170,7 +198,7 @@ An image is lazy-loaded by default, which is wrong for the one image that is alr `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}`. @@ -286,7 +314,7 @@ A background has no `srcSet`, so ask for the largest size the slot can reach and ```tsx buildNodeUrl(page, { absolute: true }); buildImageUrl(cover, { width: 1200 }, { absolute: true }).url; -getImageProps(cover, { alt: title, layout: "fluid", sizes: "auto", absolute: true }, context); +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"`. diff --git a/javascript-modules-library/src/components/JImage.spec.tsx b/javascript-modules-library/src/components/JImage.spec.tsx index 14965737..5e64a8ae 100644 --- a/javascript-modules-library/src/components/JImage.spec.tsx +++ b/javascript-modules-library/src/components/JImage.spec.tsx @@ -154,16 +154,25 @@ describe('sizes="auto"', () => { it("gives way to preload, which a shared wrapper's default cannot argue with", () => { const props = attributesOf( - JImage({ node: imageNode(), alt: "", layout: "fluid", sizes: "auto", preload: true }), + JImage({ node: imageNode(), alt: "", sizes: "auto", preload: true }), ); - // Nothing in the markup describes a fluid slot, so the safe answer is all that is left + // 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("falls back to the sizes the layout derives, not to the browser default", () => { + it("replaces auto on an eagerly loaded image too, not only on a preloaded one", () => { const props = attributesOf( - JImage({ node: imageNode(), alt: "", slotWidth: 600, sizes: "auto", loading: "eager" }), + 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"); @@ -199,7 +208,7 @@ describe("the development warnings", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); stubDevelopmentMode(true); - freshJImage({ node: imageNode(), alt: "", layout: "fluid", sizes: "auto", preload: true }); + freshJImage({ node: imageNode(), alt: "", sizes: "auto", preload: true }); const [message, ...rest] = autoSizesMessages(warn); expect(rest).toHaveLength(0); @@ -212,7 +221,7 @@ describe("the development warnings", () => { stubDevelopmentMode(false); const props = attributesOf( - freshJImage({ node: imageNode(), alt: "", layout: "fluid", sizes: "auto", preload: true }), + freshJImage({ node: imageNode(), alt: "", sizes: "auto", preload: true }), ); expect(warn).not.toHaveBeenCalled(); @@ -223,7 +232,7 @@ describe("the development warnings", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); stubDevelopmentMode(true); - freshJImage({ node: imageNode(), alt: "", layout: "fluid", sizes: "auto" }); + freshJImage({ node: imageNode(), alt: "", sizes: "auto" }); freshJImage({ node: imageNode(), alt: "", layout: "full-width", preload: true }); expect(autoSizesMessages(warn)).toHaveLength(0); @@ -314,24 +323,36 @@ describe("placeholder", () => { }); }); -describe('the "fluid" layout', () => { +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: "", layout: "fluid", sizes: "auto" }), - ); + 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", fallback: "img/placeholder.jpg" })); + 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: "" })).toBeNull(); + 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 aa25b3e9..671005f0 100644 --- a/javascript-modules-library/src/components/JImage.tsx +++ b/javascript-modules-library/src/components/JImage.tsx @@ -6,8 +6,7 @@ import { warnAutoSizesEager } from "../utils/image/devWarnings.js"; import { getImageProps, isAutoSizes, - layoutNeedsSizes, - type ImageLayout, + type ImageSlot, type ImgProps, } from "../utils/image/getImageProps.js"; import type { ImageSourceOptions } from "../utils/image/imageDefaults.js"; @@ -39,26 +38,13 @@ export type MarkupBox = * 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 JImageProps extends ImageSourceOptions { +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; - /** - * How the image occupies its slot. - * - * @default "constrained" - */ - layout?: ImageLayout; - /** The slot width in CSS pixels. Required by the `constrained` and `fixed` layouts. */ - slotWidth?: number; /** Explicit candidate widths in image pixels. Overrides the ladder the layout would derive. */ widths?: number[]; - /** - * Explicit `sizes` attribute. Required by the `fluid` and `fill` layouts. `"auto"` measures the - * real box and forces `loading="lazy"`, the only mode in which browsers read it. - */ - sizes?: string; /** Candidate ladder used by every layout but `fixed`. */ breakpoints?: readonly number[]; /** @@ -93,14 +79,20 @@ export interface JImageProps extends ImageSourceOptions { * 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 {@link JImageProps.slotWidth}, a different - * number with a different job. + * 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 JImageProps.width}. */ + /** 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", @@ -113,9 +105,9 @@ const FILL_STYLE: CSSProperties = { * 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 `slotWidth` for the layouts measured in - * CSS pixels — rather than computing candidate widths by hand. On a fluid design, where no slot has - * a pixel width, that is `layout="fluid"` with `sizes="auto"`. + * 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 @@ -126,7 +118,7 @@ const FILL_STYLE: CSSProperties = { * * @example * ```tsx - * + * * * * @@ -159,9 +151,12 @@ export function JImage({ style, ...imgAttributes }: Readonly< - JImageProps & Omit, "src" | "srcSet" | keyof JImageProps> + JImageBaseProps & Omit, "src" | "srcSet" | keyof JImageProps> > & - MarkupBox): JSX.Element | null { + 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(); // Caught here rather than at the ``, because the call site that writes one of the two is @@ -177,24 +172,17 @@ export function JImage({ // "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 the layout's own `sizes` replaces `auto`. + // 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 - ? sizes - : // A layout that derives no `sizes` of its own has only the safe, wasteful answer left - layoutNeedsSizes(layout) - ? "100vw" - : undefined; + const requestedSizes = autoOverridden ? "100vw" : sizes; const image: ImgProps | null = getImageProps( node, { alt, - layout, - slotWidth, widths, - sizes: requestedSizes, breakpoints, cacheDependency, loader, @@ -202,6 +190,9 @@ export function JImage({ 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, ); diff --git a/javascript-modules-library/src/index.ts b/javascript-modules-library/src/index.ts index 5533a1c9..80287107 100644 --- a/javascript-modules-library/src/index.ts +++ b/javascript-modules-library/src/index.ts @@ -12,6 +12,7 @@ export { Area } from "./components/Area.js"; export { JImage, type ExtraImageAttributes, + type JImageBaseProps, type JImageProps, type MarkupBox, } from "./components/JImage.js"; @@ -53,6 +54,8 @@ export { DEFAULT_BREAKPOINTS, type ImageLayout, type ImageOptions, + type ImageOptionsBase, + type ImageSlot, type ImgProps, } from "./utils/image/getImageProps.js"; export { diff --git a/javascript-modules-library/src/utils/image/getImageProps.ts b/javascript-modules-library/src/utils/image/getImageProps.ts index 627fbbe8..c5b6a687 100644 --- a/javascript-modules-library/src/utils/image/getImageProps.ts +++ b/javascript-modules-library/src/utils/image/getImageProps.ts @@ -13,6 +13,7 @@ import { type ImageSourceOptions, } from "./imageDefaults.js"; import { clampToIntrinsic, readImageMeta } from "./imageMeta.js"; +import { ladderFromSizes } from "./sizesLadder.js"; /** * How the image occupies its slot. Declaring the intent lets the library derive both `srcSet` and @@ -22,38 +23,61 @@ import { clampToIntrinsic, readImageMeta } from "./imageMeta.js"; * wide it is — and, when nothing does, whether the image sits in the normal flow or is stretched * over a parent. * - * - `constrained` (default): the image is at most `slotWidth` CSS pixels wide and shrinks with the - * viewport below that — the common case for content in a column. + * - `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). - * - `fluid`: a normal-flow slot whose width the markup cannot know — a `%`, a `fr`, a grid cell, an - * `aspect-ratio` box, a slot constrained by its height. No `slotWidth`, and `sizes` is required - * because nothing else can describe the box. On a fluid design this is most slots. + * 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. Like - * `fluid` 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. + * - `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" | "fluid" | "full-width" | "fill"; +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, `fluid`, `full-width` and `fill` 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]; -/** - * The layouts whose slot width nothing in the markup states, so no `sizes` can be derived for them - * and the caller has to supply one. An unset layout is `constrained`, which derives its own. - */ -export const layoutNeedsSizes = (layout: ImageLayout | undefined): boolean => - layout === "fluid" || layout === "fill"; - /** * `` props built from a JCR image node. * @@ -80,32 +104,12 @@ export interface ImgProps { alt: string; } -export interface ImageOptions extends ImageSourceOptions { +/** 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`, meaningless for the - * layouts whose width the markup does not state. - * - * Named apart from the `width` HTML attribute on purpose: this is how much room the layout gives - * the image, not a number that ends up in the markup. - */ - slotWidth?: number; /** Explicit candidate widths in image pixels. Overrides the ladder the layout would derive. */ widths?: number[]; - /** - * Explicit `sizes` attribute. Required by the `fluid` and `fill` layouts. - * - * `"auto"` lets the browser measure the real box, which beats any value derivable from the markup - * — and it forces `loading="lazy"`, the only mode in which browsers read it. - */ - sizes?: string; /** Candidate ladder used by every layout but `fixed`. */ breakpoints?: readonly number[]; /** @@ -123,6 +127,14 @@ export interface ImageOptions extends ImageSourceOptions { fallback?: string; } +/** + * What {@link getImageProps} takes: the alternative text, the slot description, and the overrides. + * + * `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. + */ +export type ImageOptions = ImageOptionsBase & ImageSlot; + /** * True for a `sizes` value whose first entry is `auto`. * @@ -134,79 +146,118 @@ export interface ImageOptions extends ImageSourceOptions { export const isAutoSizes = (sizes: string | undefined): boolean => sizes !== undefined && sizes.trim().split(",")[0].trim().toLowerCase() === "auto"; -/** The `slotWidth` the layouts measured in CSS pixels cannot work without. */ -const requireSlotWidth = (layout: ImageLayout, slotWidth: number | undefined): number => { - if (slotWidth === undefined) { - throw new Error( - `getImageProps: layout "${layout}" needs a slotWidth (the slot width in CSS pixels). ` + - `Use layout "fluid" when the slot is sized by CSS the markup cannot read, or ` + - `"full-width" for an image that always spans the viewport.`, - ); - } +/** 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; +} - return slotWidth; +/** + * 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" }; + + 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 }; + } }; -/** The candidate widths a layout asks for, before clamping. */ +/** 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, - slotWidth: number | undefined, + slot: ResolvedSlot, breakpoints: readonly number[], ): number[] => { - // The slot is the viewport, or a box the markup cannot measure: offer the whole ladder - if (layout === "full-width" || layoutNeedsSizes(layout)) return [...breakpoints]; - - const width = requireSlotWidth(layout, slotWidth); + // 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. - * - * Reached both from the layouts that derive one and, when `widths` was explicit and no ladder was - * asked for, from a caller who never named a slot — so it validates `slotWidth` itself rather than - * trusting {@link candidateWidths} to have run first. - */ -const derivedSizes = (layout: ImageLayout, slotWidth: number | undefined): string => { - switch (layout) { - case "full-width": - return "100vw"; - case "fixed": - return `${requireSlotWidth(layout, slotWidth)}px`; - case "constrained": { - const width = requireSlotWidth(layout, slotWidth); - return `(min-width: ${width}px) ${width}px, 100vw`; - } - case "fluid": - case "fill": - throw new Error( - `getImageProps: layout "${layout}" needs an explicit sizes, because the slot is sized by ` + - "CSS and nothing in the markup says how wide it is. " + - 'Use sizes="auto" to let the browser measure the real box (it loads the image lazily), ' + - 'or describe the slot, as in sizes="(min-width: 60rem) 33vw, 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` + `slotWidth` and the candidates and `sizes` - * are derived; on a fluid layout, where no slot has a width in CSS pixels, use `layout="fluid"` - * with `sizes="auto"` — that is the normal case, not the escape hatch. + * 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. When missing, `options.fallback` is used instead. @@ -248,6 +299,10 @@ export function getImageProps( 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; } @@ -274,10 +329,6 @@ export function getImageProps( height: layout === "fill" ? undefined : meta.intrinsicHeight, }; - /** What the caller asked for, once the layout has had its say. */ - const resolveSizes = (): string | undefined => - layoutNeedsSizes(layout) ? (sizes ?? derivedSizes(layout, slotWidth)) : sizes; - const withLoading = (props: ImgProps): ImgProps => isAutoSizes(props.sizes) ? { ...props, loading: "lazy" } : props; @@ -287,13 +338,13 @@ export function getImageProps( return withLoading({ ...base, src: buildImageUrl(node, undefined, urlOptions).url, - sizes: resolveSizes(), + sizes, }); } if (meta.intrinsicWidth === undefined) warnMissingIntrinsicSize(node); - const requested = (widths ?? candidateWidths(layout, slotWidth, breakpoints)) + const requested = (widths ?? candidateWidths(layout, slot, breakpoints)) .filter((candidate) => candidate > 0) .map((candidate) => clampToIntrinsic(candidate, meta.intrinsicWidth)) .sort((a, b) => a - b); @@ -327,8 +378,7 @@ export function getImageProps( ? [...widthByUrl].map(([url, candidate]) => `${commaSafe(url)} ${candidate}w`).join(", ") : undefined, // Below two candidates there is no choice to describe, so only an explicit `sizes` survives - sizes: - widthByUrl.size > 1 ? (resolveSizes() ?? derivedSizes(layout, slotWidth)) : resolveSizes(), + sizes: widthByUrl.size > 1 ? slot.sizes : sizes, }); } diff --git a/javascript-modules-library/src/utils/image/image.spec.ts b/javascript-modules-library/src/utils/image/image.spec.ts index 00ff0ffa..8d20b72e 100644 --- a/javascript-modules-library/src/utils/image/image.spec.ts +++ b/javascript-modules-library/src/utils/image/image.spec.ts @@ -186,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 slotWidth/, + /needs the slot described[\s\S]*slotWidth[\s\S]*sizes/, ); }); @@ -209,9 +210,12 @@ describe("getImageProps", () => { alt: "A terrace", 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"); }); @@ -309,9 +313,11 @@ describe("getImageProps", () => { // 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(/layout "constrained" needs a slotWidth/); + ).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/); }); @@ -400,6 +406,8 @@ describe("the development warnings", () => { // 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 @@ -479,13 +487,9 @@ describe("the development warnings", () => { }); }); -describe('the "fluid" layout', () => { - it("draws the whole ladder for a normal-flow slot the markup cannot measure", () => { - const props = getImageProps(imageNode({ width: 4000 }), { - alt: "", - layout: "fluid", - sizes: "auto", - }); +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`); } @@ -494,40 +498,178 @@ describe('the "fluid" layout', () => { it("keeps the intrinsic pair, which is what reserves the space in normal flow", () => { const props = getImageProps(imageNode({ width: 4000, height: 2000 }), { alt: "", - layout: "fluid", sizes: "auto", }); expect(props).toMatchObject({ width: 4000, height: 2000, loading: "lazy" }); }); - it("refuses to guess a sizes it cannot derive, and says what to write", () => { - expect(() => getImageProps(imageNode({ width: 4000 }), { alt: "", layout: "fluid" })).toThrow( - /layout "fluid" needs an explicit sizes/, - ); + 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 fluid = getImageProps(node, { alt: "", layout: "fluid", sizes: "auto" }); + const inFlow = getImageProps(node, { alt: "", sizes: "auto" }); const fill = getImageProps(node, { alt: "", layout: "fill", sizes: "auto" }); - expect(fluid.src).toBe(fill.src); - expect(fluid.srcSet).toBe(fill.srcSet); - expect(fluid.sizes).toBe(fill.sizes); - expect(fluid.loading).toBe(fill.loading); + 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", fallback: "img/placeholder.jpg" }, {}), + 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: "" }, {})).toBeNull(); + expect(getImagePropsWithContext(undefined, { alt: "", slotWidth: 400 }, {})).toBeNull(); }); }); @@ -554,8 +696,9 @@ describe('the "fill" layout', () => { }); 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 an explicit sizes/, + /layout "fill" needs the slot described/, ); }); 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; +}