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
6 changes: 6 additions & 0 deletions .chachalog/4M3L-Pqd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
# Allowed version bumps: patch, minor, major
javascript-modules: minor
---

Improved `<JImage>` and `getImageProps` to use the resized URL a module returns for an image file, and to append `?w=` only when none is returned.
48 changes: 36 additions & 12 deletions jahia-test-module/src/react/server/views/testImage/TestImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,32 @@ jahiaComponent(
return <div data-testid="image_missing_fixture">Every reference is required</div>;
}

// A stand-in node implements only what getImageProps reads.
const standIn = (isDefault: boolean, getUrl: (args?: string[]) => string) =>
({
getIdentifier: () => "stand-in",
hasProperty: () => false,
hasNode: () => false,
getProvider: () => ({ isDefault: () => isDefault }),
// buildNodeUrl collects this as a cache dependency, so it must be a real node's.
getCanonicalPath: () => large.getCanonicalPath(),
getUrl,
}) as unknown as JCRNodeWrapper;

// Maps ["w:300", "h:300"] to "w_300,h_300/", the shape of a Cloudinary transformation.
const argPath = (args?: string[]) =>
args ? `${args.map((arg) => arg.replace(":", "_")).join(",")}/` : "";

// A DAM node answers getUrl(args) with a URL on its own host, and the resize travels in those
// arguments rather than in a query string. No such provider is mounted here, so this stands in
// for one, implementing only what getImageProps reads. The URL mimics a Cloudinary path: a
// two-argument resize carries the comma the srcset workaround exists for.
const damNode = {
getIdentifier: () => "dam-stand-in",
hasProperty: () => false,
hasNode: () => false,
getProvider: () => ({ isDefault: () => false }),
// buildNodeUrl collects this as a cache dependency, so it must be a real node's.
getCanonicalPath: () => large.getCanonicalPath(),
getUrl: (args?: string[]) =>
`https://media.dam.test/${args ? `${args.map((arg) => arg.replace(":", "_")).join(",")}/` : ""}asset.jpg`,
} as unknown as JCRNodeWrapper;
// for one. A two-argument resize carries the comma the srcset workaround exists for.
const damNode = standIn(false, (args) => `https://media.dam.test/${argPath(args)}asset.jpg`);

// A jnt:file decorator answers getUrl(args) on the default provider with a sized URL.
const decoratedNode = standIn(true, (args) => `/files/sized/${argPath(args)}asset.jpg`);

// With no decorator, getUrl(args) returns getUrl(), as JCRNodeWrapperImpl does.
const plainNode = standIn(true, () => "/files/default/asset.jpg");

return (
<>
Expand Down Expand Up @@ -119,6 +131,18 @@ jahiaComponent(
<Case id="dam_density" props={getImageProps(damNode, { width: 400 })} />
<Case id="dam_density_both" props={getImageProps(damNode, { width: 300, height: 300 })} />

{/* The default provider: a decorator's sized URL wins, otherwise `?w=` is appended. */}
<Case id="decorated_responsive" props={getImageProps(decoratedNode)} />
<Case
id="decorated_density_both"
props={getImageProps(decoratedNode, { width: 300, height: 300 })}
/>
<Case id="plain_responsive" props={getImageProps(plainNode)} />
<Case
id="plain_density_both"
props={getImageProps(plainNode, { width: 300, height: 300 })}
/>

{/* <JImage>: the cases above cover every branch of the computed attributes; these prove
they land on a real <img>, in the spellings a browser reads. */}

Expand Down
2 changes: 2 additions & 0 deletions javascript-modules-library/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ This snippet produces something like the following HTML:

On a default Jahia instance, all `?w=` URLs will serve the original image. Your production instance requires a DAM ([Cloudinary](https://www.jahia.com/integrations/cloudinary), [Keepeek](https://www.jahia.com/integrations/keepeek)) or an image resizer ([Cloudimage](https://www.jahia.com/integrations/cloudimage)) for the responsive behavior to work correctly.

A module can also decorate `jnt:file` nodes and return a sized URL from `node.getUrl(["w:376"])`. `JImage` asks the node first, and appends `?w=` only when the URL comes back unchanged.

Images are lazy-loaded by default. For the image above the fold, typically the hero, tell the browser to fetch it first:

```tsx
Expand Down
21 changes: 12 additions & 9 deletions javascript-modules-library/src/utils/image/getImageProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,22 +84,25 @@ const isVector = (node: JCRNodeWrapper) => {
const encodeComma = (url: string) => url.replaceAll(",", "%2C");

/**
* There is no real way to know what will resize the image, so we make this assumption:
* The node is asked for a sized URL first, through `node.getUrl(["w:640", ...])`:
*
* - If a DAM is set (node.getProvider().isDefault() === false), the DAM will resize the image
* - Otherwise, a page filter will rewrite the image URL to for resizing
* - A DAM (node.getProvider().isDefault() === false) always resizes through these arguments
* - On the default provider, a `jnt:file` decorator may answer `getUrl(List)` with a sized URL
* - When no decorator answers, `getUrl(List)` returns `getUrl()`, and the size goes in `?w=` / `?h=`
* for an image resizer in front of Jahia
*/
const resizedUrl = (
node: JCRNodeWrapper,
args: { w?: string; h?: string },
options: Parameters<typeof buildNodeUrl>[1],
) =>
encodeComma(
buildNodeUrl(
node,
node.getProvider().isDefault() ? { ...options, parameters: args } : { ...options, args },
),
) => {
const sized =
!node.getProvider().isDefault() ||
node.getUrl(Object.entries(args).map(([key, value]) => `${key}:${value}`)) !== node.getUrl();
return encodeComma(
buildNodeUrl(node, sized ? { ...options, args } : { ...options, parameters: args }),
);
};

/**
* Default set of image widths for the `srcset` attribute.
Expand Down
37 changes: 37 additions & 0 deletions tests/cypress/e2e/ui/imageTest.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,43 @@ describe("Images", () => {
});
});

describe("default provider", () => {
// The view stands in for two default-provider nodes. The decorated one answers getUrl(args)
// with `/files/sized/<args>/asset.jpg`. The plain one returns the same URL for any arguments.
it("uses the sized URL a decorator returns, with no query string", () => {
props("decorated_responsive").should((p) => {
expect(p.src).to.include(`/files/sized/w_${DEFAULT_SRCSET.at(-1)}/asset.jpg`);
expect(p.src).not.to.include("?");
const urls = candidateUrls(p.srcSet);
expect(urls).to.have.length(DEFAULT_SRCSET.length);
urls.forEach((url, i) => {
expect(url).to.include(`/files/sized/w_${DEFAULT_SRCSET[i]}/asset.jpg`);
expect(url).not.to.include("?");
});
});
});

it("passes the width and the height to the decorator", () => {
props("decorated_density_both").should((p) => {
expect(candidateUrls(p.srcSet)[0]).to.include("/files/sized/w_1200%2Ch_1200/asset.jpg");
expect(p.src).to.include("/files/sized/w_300%2Ch_300/asset.jpg");
expect(p.src).not.to.include("?");
});
});

it("appends the size as query parameters when no decorator answers", () => {
props("plain_responsive").should((p) => {
expect(p.src).to.include(`/files/default/asset.jpg?w=${DEFAULT_SRCSET.at(-1)}`);
const urls = candidateUrls(p.srcSet);
expect(urls).to.have.length(DEFAULT_SRCSET.length);
urls.forEach((url, i) => expect(url).to.include(`?w=${DEFAULT_SRCSET[i]}`));
});
props("plain_density_both").should((p) => {
expect(p.src).to.include("/files/default/asset.jpg?w=300&h=300");
});
});
});

describe("JImage", () => {
it("renders a responsive image: src, candidate set, sizes, intrinsic size and title", () => {
img("jimage_responsive")
Expand Down
Loading