Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .chachalog/img7Kq2Ls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — `<JImage node={cover} alt={title} width={400} />` — 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 — `<JImage node={cover} alt={title} slotWidth={400} />` — 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.
12 changes: 12 additions & 0 deletions .chachalog/img9Fv3Rt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
# Allowed version bumps: patch, minor, major
javascript-modules: minor
---

Made the image API usable on a fluid site: a slot described by its own `sizes`, first-class `sizes="auto"`, full `<img>` attribute pass-through, a pluggable loader, and CSS background and absolute URLs. (#766)

A slot is described **once**, and the candidate files follow that one description. `<JImage node={cover} alt={title} slotWidth={400} />` when the markup knows the slot's width in CSS pixels; `<JImage node={card} alt="" sizes="auto" />` 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 `<img>` 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.
2 changes: 1 addition & 1 deletion docs/1-getting-started/4-making-a-blog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jahiaComponent(
({ "jcr:title": title, subtitle, authors, cover }: Props, { currentNode }) => {
return (
<article className={classes.card}>
<JImage node={cover} alt={title} width={320} />
<JImage node={cover} alt={title} slotWidth={320} />
<h3>
<a href={buildNodeUrl(currentNode)}>{title}</a>
</h3>
Expand Down
294 changes: 243 additions & 51 deletions docs/2-guides/8-images/README.md

Large diffs are not rendered by default.

358 changes: 358 additions & 0 deletions javascript-modules-library/src/components/JImage.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,358 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { JCRNodeWrapper } from "org.jahia.services.content";

// `JImage` is a plain function of its props: rendering it through React would only add a tree to
// walk back down. The one hook it calls is the server context, which outside the engine is ours.
const serverContext = () => ({
bundleKey: "test-module",
renderContext: {
getRequest: () => ({ getContextPath: () => "" }),
getResponse: () => ({ encodeURL: (url: string) => url }),
getURLGenerator: () => ({ getCurrentModule: () => "/modules/test-module" }),
},
});
vi.mock("../hooks/useServerContext.js", () => ({ useServerContext: serverContext }));
vi.mock("../hooks/useServerContext", () => ({ useServerContext: serverContext }));

// A render context means a cache dependency, which the engine's `server` bridge registers
Reflect.set(globalThis, "server", { render: { addCacheDependency: () => {} } });

const { JImage } = await import("./JImage.js");

/** A JCR file node holding an image, with just the surface the image code touches. */
const imageNode = ({
url = "/files/photo.jpg",
width = 4000,
height = 2000,
thumbnails = ["thumbnail", "thumbnail2"],
}: { url?: string; width?: number; height?: number; thumbnails?: string[] } = {}) =>
({
getPath: () => "/sites/test/files/photo.jpg",
getProvider: () => ({ isDefault: () => true }),
getResolveSite: () => ({ getServerName: () => "www.example.com" }),
getUrl: () => url,
getThumbnailUrl: (name: string) => {
if (!thumbnails.includes(name)) throw new Error(`no thumbnail ${name}`);
return `${url}?t=${name}`;
},
getNode: (child: string) =>
child === "jcr:content" ? { getPropertyAsString: () => "image/jpeg" } : null,
getProperty: (property: string) => {
const value = property === "j:width" ? width : property === "j:height" ? height : undefined;
if (value === undefined) throw new Error(`no such property: ${property}`);
return { getLong: () => value };
},
}) as unknown as JCRNodeWrapper;

/** The attributes the component would put on the `<img>`. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const attributesOf = (element: ReturnType<typeof JImage>): Record<string, any> =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(element as any).props;

describe("attribute pass-through", () => {
it("forwards an <img> attribute the component has no opinion about", () => {
const props = attributesOf(
JImage({
node: imageNode(),
alt: "A terrace",
slotWidth: 600,
id: "cover",
className: "cover",
decoding: "async",
referrerPolicy: "no-referrer",
}),
);
expect(props).toMatchObject({
id: "cover",
className: "cover",
decoding: "async",
referrerPolicy: "no-referrer",
});
});

it("emits the height attribute, which a caller may need as the box itself", () => {
const props = attributesOf(
JImage({ node: imageNode(), alt: "", slotWidth: 48, width: 48, height: 48 }),
);
expect(props).toMatchObject({ width: 48, height: 48 });
});

it("reserves space from the intrinsic pair when the caller writes neither", () => {
const props = attributesOf(JImage({ node: imageNode(), alt: "", slotWidth: 600 }));
expect(props).toMatchObject({ width: 4000, height: 2000, loading: "lazy" });
});

it("refuses half a box, and names the prop the caller probably meant", () => {
// TypeScript rejects this spelling; the throw catches the JavaScript caller and the spread
// @ts-expect-error `width` and `height` are required together
expect(() => JImage({ node: imageNode(), alt: "", slotWidth: 600, width: 48 })).toThrow(
/pass both or neither.*slotWidth/s,
);
});

it("takes the caller's box over the intrinsic one", () => {
const props = attributesOf(
JImage({ node: imageNode(), alt: "", slotWidth: 600, width: 48, height: 24 }),
);
expect(props).toMatchObject({ width: 48, height: 24, loading: "lazy" });
});
});

describe("the attributes map", () => {
it("spreads a record onto the element", () => {
const props = attributesOf(
JImage({
node: imageNode(),
alt: "",
slotWidth: 600,
attributes: { "data-testid": "cover", "itemProp": "image" },
}),
);
expect(props).toMatchObject({ "data-testid": "cover", "itemProp": "image" });
});

it("hands the resolved image to the function form", () => {
const props = attributesOf(
JImage({
node: imageNode(),
alt: "",
slotWidth: 600,
attributes: ({ src, width }) => ({ "data-src": src, "data-width": width }),
}),
);
// The resolved image, not the props the caller wrote: `src` is the smallest candidate and
// `width` the intrinsic one
expect(props["data-src"]).toBe("/files/photo.jpg?w=320");
expect(props["data-width"]).toBe(4000);
});

it("is applied last, so it can override anything the component computed", () => {
const props = attributesOf(
JImage({ node: imageNode(), alt: "", slotWidth: 600, attributes: { loading: "eager" } }),
);
expect(props.loading).toBe("eager");
});
});

describe("preload", () => {
it("loads the image eagerly and at high priority", () => {
const props = attributesOf(
JImage({ node: imageNode(), alt: "", layout: "full-width", preload: true }),
);
expect(props).toMatchObject({ loading: "eager", fetchPriority: "high" });
});
});

describe('sizes="auto"', () => {
it("loads lazily, because that is the only mode a browser reads it in", () => {
const props = attributesOf(
JImage({ node: imageNode(), alt: "", layout: "fill", sizes: "auto" }),
);
expect(props).toMatchObject({ sizes: "auto", loading: "lazy" });
});

it("gives way to preload, which a shared wrapper's default cannot argue with", () => {
const props = attributesOf(
JImage({ node: imageNode(), alt: "", sizes: "auto", preload: true }),
);
// A slot spelled with `sizes` carries no width to derive a replacement from, so the safe,
// wasteful answer is all that is left
expect(props.sizes).toBe("100vw");
expect(props).toMatchObject({ loading: "eager", fetchPriority: "high" });
});

it("replaces auto on an eagerly loaded image too, not only on a preloaded one", () => {
const props = attributesOf(
JImage({ node: imageNode(), alt: "", sizes: "auto", loading: "eager" }),
);
expect(props.sizes).toBe("100vw");
expect(props.loading).toBe("eager");
});

it("leaves a slot stated in CSS pixels alone, which never asked for auto", () => {
const props = attributesOf(
JImage({ node: imageNode(), alt: "", slotWidth: 600, loading: "eager" }),
);
expect(props.sizes).toBe("(min-width: 600px) 600px, 100vw");
expect(props.loading).toBe("eager");
});
});

describe("the development warnings", () => {
/** The engine injects `server` as a global; a test provides only the parts under test. */
const stubDevelopmentMode = (developmentMode: boolean) => {
Reflect.set(globalThis, "server", {
render: { addCacheDependency: () => {} },
config: { isDevelopmentMode: () => developmentMode },
});
};

/** Each warning is printed once per engine lifetime, so each test needs its own module copy. */
let freshJImage: typeof JImage;
beforeEach(async () => {
vi.resetModules();
({ JImage: freshJImage } = await import("./JImage.js"));
});

afterEach(() => {
vi.restoreAllMocks();
Reflect.set(globalThis, "server", { render: { addCacheDependency: () => {} } });
});

/** The `?w=` candidates warn on this instance too, so each test reads only its own message. */
const autoSizesMessages = (warn: { mock: { calls: unknown[][] } }): string[] =>
warn.mock.calls.map(([message]) => String(message)).filter((m) => m.includes('sizes="auto"'));

it('reports the image that asked for both sizes="auto" and an eager load', () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
stubDevelopmentMode(true);

freshJImage({ node: imageNode(), alt: "", sizes: "auto", preload: true });

const [message, ...rest] = autoSizesMessages(warn);
expect(rest).toHaveLength(0);
expect(message).toContain("/sites/test/files/photo.jpg");
expect(message).toContain('sizes="100vw"');
});

it("says nothing in production, where the page still renders", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
stubDevelopmentMode(false);

const props = attributesOf(
freshJImage({ node: imageNode(), alt: "", sizes: "auto", preload: true }),
);

expect(warn).not.toHaveBeenCalled();
expect(props.sizes).toBe("100vw");
});

it("says nothing when the two never met", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
stubDevelopmentMode(true);

freshJImage({ node: imageNode(), alt: "", sizes: "auto" });
freshJImage({ node: imageNode(), alt: "", layout: "full-width", preload: true });

expect(autoSizesMessages(warn)).toHaveLength(0);
});
});

describe('the "fill" layout', () => {
it("positions the image over its parent, which markup cannot express", () => {
const props = attributesOf(
JImage({ node: imageNode(), alt: "", layout: "fill", sizes: "50vw" }),
);
expect(props.style).toMatchObject({ position: "absolute", inset: 0, width: "100%" });
expect(props.width).toBeUndefined();
});

it("lets a caller's style win over the positioning it suggests", () => {
const props = attributesOf(
JImage({
node: imageNode(),
alt: "",
layout: "fill",
sizes: "50vw",
style: { position: "fixed", objectFit: "cover" },
}),
);
expect(props.style).toMatchObject({ position: "fixed", objectFit: "cover" });
});

it("loads lazily on the strength of the parent's box", () => {
const props = attributesOf(
JImage({ node: imageNode(), alt: "", layout: "fill", sizes: "50vw" }),
);
expect(props.loading).toBe("lazy");
});
});

describe("placeholder", () => {
it('paints the smallest Jahia thumbnail under a "blur" image', () => {
const props = attributesOf(
JImage({ node: imageNode(), alt: "", slotWidth: 600, placeholder: "blur" }),
);
expect(props.style).toMatchObject({
backgroundImage: 'url("/files/photo.jpg?t=thumbnail")',
backgroundSize: "cover",
});
});

it("prefers a data URI the caller supplied", () => {
const props = attributesOf(
JImage({
node: imageNode(),
alt: "",
slotWidth: 600,
placeholder: "blur",
blurDataURL: "data:image/png;base64,AAAA",
}),
);
expect(props.style.backgroundImage).toBe('url("data:image/png;base64,AAAA")');
});

it("takes a data URI as the placeholder value itself", () => {
const props = attributesOf(
JImage({
node: imageNode(),
alt: "",
slotWidth: 600,
placeholder: "data:image/gif;base64,BBBB",
}),
);
expect(props.style.backgroundImage).toBe('url("data:image/gif;base64,BBBB")');
});

it("paints nothing when Jahia generated no thumbnail", () => {
const props = attributesOf(
JImage({
node: imageNode({ thumbnails: [] }),
alt: "",
slotWidth: 600,
placeholder: "blur",
}),
);
expect(props.style).toBeUndefined();
});

it("leaves the style alone by default", () => {
const props = attributesOf(JImage({ node: imageNode(), alt: "", slotWidth: 600 }));
expect(props.style).toBeUndefined();
});
});

describe("a slot described by its sizes", () => {
it("carries no CSS of its own: it is an ordinary image in the normal flow", () => {
const props = attributesOf(JImage({ node: imageNode(), alt: "", sizes: "auto" }));
expect(props.style).toBeUndefined();
// Unlike `fill`, the intrinsic pair survives, and it is what reserves the space
expect(props).toMatchObject({ width: 4000, height: 2000, loading: "lazy" });
});

it("refuses the slot described twice, in the markup and in the string", () => {
expect(() =>
// @ts-expect-error slotWidth and sizes are two descriptions of one slot
JImage({
node: imageNode(),
alt: "",
slotWidth: 400,
sizes: "(min-width: 1024px) 33vw, 100vw",
}),
).toThrow(/takes a slotWidth or a sizes, never both/);
});
});

describe("a missing node", () => {
it("renders the module asset offered as a fallback", () => {
const props = attributesOf(
JImage({ alt: "Nothing yet", slotWidth: 400, fallback: "img/placeholder.jpg" }),
);
expect(props.src).toBe("/modules/test-module/img/placeholder.jpg");
});

it("renders nothing at all when there is no fallback either", () => {
expect(JImage({ alt: "", slotWidth: 400 })).toBeNull();
});
});
Loading
Loading