diff --git a/.chachalog/img7Kq2Ls.md b/.chachalog/img7Kq2Ls.md
new file mode 100644
index 00000000..da1cfd31
--- /dev/null
+++ b/.chachalog/img7Kq2Ls.md
@@ -0,0 +1,8 @@
+---
+# Allowed version bumps: patch, minor, major
+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.
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 b70ea1da..28712c08 100644
--- a/docs/1-getting-started/4-making-a-blog/README.md
+++ b/docs/1-getting-started/4-making-a-blog/README.md
@@ -37,13 +37,13 @@ export type Props = {
};
```
-Let's also create a simple view to render the blog post as a card:
+Let's also create a simple view to render the blog post as a card. The cover is rendered with `JImage` rather than a plain `
`: it asks for a file sized for the card instead of the full-resolution original, reserves the space the image will take so the layout does not jump, and flushes the cached card when an editor replaces the picture. See the [images guide](../../2-guides/8-images/README.md) for the full story.
src/components/BlogPost/default.server.tsx
```tsx
-import { buildNodeUrl, jahiaComponent } from "@jahia/javascript-modules-library";
+import { buildNodeUrl, JImage, jahiaComponent } from "@jahia/javascript-modules-library";
import type { Props } from "./types.js";
import classes from "./component.module.css";
@@ -56,7 +56,7 @@ jahiaComponent(
({ "jcr:title": title, subtitle, authors, cover }: Props, { currentNode }) => {
return (
-
+
diff --git a/docs/2-guides/8-images/README.md b/docs/2-guides/8-images/README.md
new file mode 100644
index 00000000..b8ccfe82
--- /dev/null
+++ b/docs/2-guides/8-images/README.md
@@ -0,0 +1,147 @@
+---
+page:
+ $path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/images
+ jcr:title: Rendering Images
+ j:templateName: documentation
+content:
+ $subpath: document-area/content
+---
+
+Content images come from the JCR, and rendering one well means more than pointing an `
` at it: a browser should download a file sized for the slot it will occupy, the space it needs should be reserved before it arrives, editing the image should flush the cached fragments that show it, and a screen reader should be told what it is. The `JImage` component does all of that from one declaration.
+
+## The short version
+
+```tsx
+import { JImage, jahiaComponent } from "@jahia/javascript-modules-library";
+import type { JCRNodeWrapper } from "org.jahia.services.content";
+
+jahiaComponent(
+ { nodeType: "example:article", componentType: "view" },
+ ({ title, cover }: { title: string; cover?: JCRNodeWrapper }) => (
+
+ {title}
+
+
+ ),
+);
+```
+
+That renders a `
` with a `src` sized for the slot, a `srcSet` of alternatives the browser can pick from, a matching `sizes`, the image's intrinsic `width` and `height` so the layout does not shift when it loads, `loading="lazy"`, and a registered cache dependency on the image node.
+
+## Declare the layout, not the numbers
+
+The one number you provide is `width`: how wide the image's slot is, in CSS pixels. How that slot behaves is the `layout`:
+
+| `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 |
+
+```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.
+
+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.
+
+### 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.
+
+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 |
+
+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.
+
+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.
+
+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`.
+
+### 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.
+
+## Above the fold: `priority`
+
+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.
+
+## 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.
+
+## 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? |
+| ----------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `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 |
+
+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.
+
+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.
+
+## Images inside an island
+
+An island's props are serialized, so a React element cannot be one of them, and its server-rendered children are frozen — a client component cannot re-render them. A gallery that swaps images on click therefore needs image **data**, which is exactly what `getImageProps` returns:
+
+```tsx
+// gallery.server.tsx
+import { getImageProps, Island } from "@jahia/javascript-modules-library";
+
+const images = photos.map((photo) => getImageProps(photo, { alt: title, width: 800 }));
+
+;
+```
+
+```tsx
+// Gallery.client.tsx
+import type { ImageProps } from "@jahia/javascript-modules-library";
+
+export default function Gallery({ images }: { images: ImageProps[] }) {
+ const [current, setCurrent] = useState(0);
+ return
setCurrent((i) => i + 1)} />;
+}
+```
+
+`ImageProps` is plain, serializable data, and `alt` is required there too.
+
+## 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:
+
+```tsx
+server.render.addCacheDependency({ node: imageNode }, renderContext);
+```
+
+## Reference
+
+- `JImage` — the component; renders an unstyled `
`, so pass a `className`. Server-side only.
+- `getImageProps(node, options)` — the same props as plain data, for islands and for cases where you own the element.
+- `buildImageUrl(node, size)` — one URL and the channel that carried the size.
+- `readImageMeta(node)` — mime type and intrinsic dimensions, if you need them directly.
diff --git a/javascript-modules-engine-java/.java-ts-bind/package.json b/javascript-modules-engine-java/.java-ts-bind/package.json
index 1fd7be06..bdb6e83d 100644
--- a/javascript-modules-engine-java/.java-ts-bind/package.json
+++ b/javascript-modules-engine-java/.java-ts-bind/package.json
@@ -85,6 +85,7 @@
"org.jahia.services.content.JCRItemWrapper",
"org.jahia.services.content.JCRNodeIteratorWrapper",
"org.jahia.services.content.JCRNodeWrapper",
+ "org.jahia.services.content.JCRStoreProvider",
"org.jahia.services.content.JCRPropertyWrapper",
"org.jahia.services.content.JCRSessionWrapper",
"org.jahia.services.content.JCRValueWrapper",
@@ -248,6 +249,10 @@
"org.jahia.services.content.JCRNodeWrapper.getAncestor.*",
"org.jahia.services.content.JCRNodeWrapper.getCanonicalPath",
"org.jahia.services.content.JCRNodeWrapper.getDisplayableName",
+ "org.jahia.services.content.JCRNodeWrapper.getProvider",
+ "org.jahia.services.content.JCRStoreProvider.isDefault",
+ "org.jahia.services.content.JCRStoreProvider.getKey",
+ "org.jahia.services.content.JCRStoreProvider.getMountPoint",
"org.jahia.services.content.JCRNodeWrapper.getExistingLocales",
"org.jahia.services.content.JCRNodeWrapper.getI18N",
"org.jahia.services.content.JCRNodeWrapper.getI18Ns",
@@ -442,7 +447,6 @@
"org.jahia.services.channels.Channel",
"org.jahia.services.content.JCRNodeWrapper.NodeNamingConflictResolutionStrategy",
"org.jahia.services.content.JCRSessionFactory",
- "org.jahia.services.content.JCRStoreProvider",
"org.jahia.services.content.JCRTemplate",
"org.jahia.services.content.JCRWorkspaceWrapper.LockManagerWrapper",
"org.jahia.services.content.VersionInfo",
diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/ConfigHelper.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/ConfigHelper.java
index ea7ea247..a3ecdf76 100644
--- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/ConfigHelper.java
+++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/ConfigHelper.java
@@ -2,12 +2,14 @@
import org.graalvm.polyglot.proxy.ProxyArray;
import org.graalvm.polyglot.proxy.ProxyObject;
+import org.jahia.settings.SettingsBean;
import org.jahia.taglibs.functions.Functions;
import java.util.HashMap;
/**
- * Java helper to expose OSGi configuration values to Javascript code
+ * Java helper to expose OSGi configuration values, and the mode the instance itself runs in,
+ * to Javascript code
*/
public class ConfigHelper {
/**
@@ -75,4 +77,18 @@ public String getConfigValue(String configPid, String key) {
public String getConfigFactoryValue(String factoryPid, String factoryIdentifier, String key) {
return Functions.getConfigFactoryValue(factoryPid, factoryIdentifier, key);
}
+
+ /**
+ * Tell whether the instance runs in development mode, the {@code developmentMode} setting of
+ * {@code jahia.properties}. Javascript code uses it to emit diagnostics that help a developer
+ * and that a production instance must never pay for.
+ *
+ * @return true when the instance runs in development mode
+ */
+ public boolean isDevelopmentMode() {
+ // SettingsBean has no instance before Jahia has finished starting, and a diagnostic helper
+ // must not be the thing that fails then
+ SettingsBean settings = SettingsBean.getInstance();
+ return settings != null && settings.isDevelopmentMode();
+ }
}
diff --git a/javascript-modules-library/package.json b/javascript-modules-library/package.json
index 727a4210..d6fa181b 100644
--- a/javascript-modules-library/package.json
+++ b/javascript-modules-library/package.json
@@ -22,7 +22,8 @@
"scripts": {
"build": "tsc && node post-build.js && yarn pack --out dist/package.tgz && publint",
"clean": "rm -rf dist",
- "lint": "yarn run --top-level lint"
+ "lint": "yarn run --top-level lint",
+ "test": "vitest run"
},
"devDependencies": {
"@0no-co/graphql.web": "^1.2.0",
@@ -33,7 +34,8 @@
"i18next": "^25.7.3",
"publint": "^0.3.16",
"react-i18next": "^16.5.0",
- "typescript": "^5.9.3"
+ "typescript": "^5.9.3",
+ "vitest": "^4.1.11"
},
"peerDependencies": {
"@graphql-typed-document-node/core": "^3.2.0",
diff --git a/javascript-modules-library/src/components/JImage.tsx b/javascript-modules-library/src/components/JImage.tsx
new file mode 100644
index 00000000..4c96ae52
--- /dev/null
+++ b/javascript-modules-library/src/components/JImage.tsx
@@ -0,0 +1,97 @@
+import type { ImgHTMLAttributes, JSX } from "react";
+import type { JCRNodeWrapper } from "org.jahia.services.content";
+import { useServerContext } from "../hooks/useServerContext.js";
+import { getImageProps, type ImageLayout } from "../utils/image/getImageProps.js";
+import { buildModuleFileUrl } from "../utils/urlBuilder/urlBuilder.js";
+
+/**
+ * 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`.
+ *
+ * Server-side only, because it registers the cache dependency. A client component receives image
+ * data instead: build it with {@link getImageProps} and pass it through ``.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ * ```;
+ *
+ * @returns The `
` element.
+ */
+export function JImage({
+ node,
+ alt,
+ layout,
+ width,
+ widths,
+ sizes,
+ priority = false,
+ fallback,
+ loading,
+ fetchPriority,
+ ...imgAttributes
+}: Readonly<
+ {
+ /** The file node holding the image. When missing, `fallback` is rendered instead. */
+ node?: JCRNodeWrapper | null;
+ /** Alternative text; `""` declares the image decorative. */
+ alt: string;
+ /**
+ * How the image occupies its slot.
+ *
+ * @default "constrained"
+ */
+ layout?: ImageLayout;
+ /** The slot width in CSS pixels. Required by the `constrained` and `fixed` layouts. */
+ width?: number;
+ /** Explicit candidate widths in image pixels. Escape hatch: prefer `layout` + `width`. */
+ widths?: number[];
+ /**
+ * Marks the image as the page's largest above-the-fold element: it loads eagerly, at high
+ * priority, instead of being lazy-loaded.
+ */
+ priority?: boolean;
+ /**
+ * A module static asset (`import placeholder from "/static/img/placeholder.jpg"`) rendered when
+ * `node` is missing, so an unfilled content property does not leave a broken image.
+ */
+ fallback?: string;
+ } & Omit, "src" | "srcSet" | "width" | "height" | "alt">
+>): JSX.Element | null {
+ const context = useServerContext();
+
+ const props = node
+ ? getImageProps(node, { alt, layout, width, widths, sizes }, context)
+ : fallback
+ ? { src: buildModuleFileUrl(fallback, {}, context), alt: alt.trim() }
+ : null;
+
+ if (!props) return null;
+
+ const { width: intrinsicWidth, height: intrinsicHeight } = props as {
+ width?: number;
+ height?: number;
+ };
+
+ return (
+
+ );
+}
diff --git a/javascript-modules-library/src/config-helper-augmentation.d.ts b/javascript-modules-library/src/config-helper-augmentation.d.ts
new file mode 100644
index 00000000..de9eca5a
--- /dev/null
+++ b/javascript-modules-library/src/config-helper-augmentation.d.ts
@@ -0,0 +1,17 @@
+/**
+ * Temporary: `ConfigHelper.isDevelopmentMode()` exists in the engine, but the typings it is
+ * generated into (`target/types`) are only refreshed by a Maven build. This file adds it back so
+ * that the library type-checks against a tree built without one.
+ *
+ * `.java-ts-bind/package.json` already whitelists `ConfigHelper.*`, so the next regeneration of
+ * `target/types` makes this file redundant — **delete it then**. It is not shipped: `post-build.js`
+ * copies only `globals.d.ts` and the generated Java types into `dist`.
+ *
+ * @see {@link https://github.com/Jahia/javascript-modules/issues/748}
+ */
+declare module "org.jahia.modules.javascript.modules.engine.js.server" {
+ interface ConfigHelper {
+ /** True on an instance started with `developmentMode` in `jahia.properties`. */
+ isDevelopmentMode(): boolean;
+ }
+}
diff --git a/javascript-modules-library/src/index.ts b/javascript-modules-library/src/index.ts
index 276252f4..4962a52c 100644
--- a/javascript-modules-library/src/index.ts
+++ b/javascript-modules-library/src/index.ts
@@ -9,6 +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";
// Declaration and registration
export { jahiaComponent } from "./framework/jahiaComponent.js";
@@ -30,6 +31,23 @@ export {
buildModuleFileUrl,
} from "./utils/urlBuilder/urlBuilder.js";
+// Images
+export {
+ buildImageUrl,
+ THUMBNAIL_WIDTHS,
+ type ImageResizeChannel,
+ type ImageUrl,
+} from "./utils/image/buildImageUrl.js";
+export {
+ getImageProps,
+ inspectImageChannel,
+ DEFAULT_BREAKPOINTS,
+ type ImageLayout,
+ type ImageOptions,
+ type ImageProps,
+} from "./utils/image/getImageProps.js";
+export { readImageMeta, type ImageMeta } from "./utils/image/imageMeta.js";
+
// I18n
export { getSiteLocales } from "./utils/i18n.js";
diff --git a/javascript-modules-library/src/jcr-provider-augmentation.d.ts b/javascript-modules-library/src/jcr-provider-augmentation.d.ts
new file mode 100644
index 00000000..6fa9a90d
--- /dev/null
+++ b/javascript-modules-library/src/jcr-provider-augmentation.d.ts
@@ -0,0 +1,24 @@
+/**
+ * Temporary: `JCRNodeWrapper.getProvider()` and `JCRStoreProvider.isDefault()` exist in Jahia core
+ * but were excluded from the generated typings by the java-ts-bind configuration. This file adds
+ * back only what {@link buildImageUrl} needs to route a resize to the right channel.
+ *
+ * `.java-ts-bind/package.json` now includes `JCRStoreProvider` and whitelists both methods, so the
+ * next regeneration of `target/types` makes this file redundant — **delete it then**. It is not
+ * shipped: `post-build.js` copies only `globals.d.ts` and the generated Java types into `dist`.
+ *
+ * @see {@link https://github.com/Jahia/javascript-modules/issues/739}
+ */
+declare module "org.jahia.services.content" {
+ interface JCRStoreProvider {
+ /** True for the default provider, false for an external provider mount (a DAM). */
+ isDefault(): boolean;
+ /** The provider key, unique per mount. */
+ getKey(): string;
+ }
+
+ interface JCRNodeWrapper {
+ /** The provider this node is stored in. */
+ getProvider(): JCRStoreProvider;
+ }
+}
diff --git a/javascript-modules-library/src/utils/image/buildImageUrl.ts b/javascript-modules-library/src/utils/image/buildImageUrl.ts
new file mode 100644
index 00000000..8896fa4d
--- /dev/null
+++ b/javascript-modules-library/src/utils/image/buildImageUrl.ts
@@ -0,0 +1,154 @@
+import type { JCRNodeWrapper } from "org.jahia.services.content";
+import type { RenderContext, Resource } from "org.jahia.services.render";
+import { buildNodeUrl } from "../urlBuilder/urlBuilder.js";
+import { clampToIntrinsic, readImageMeta, type ImageMeta } from "./imageMeta.js";
+
+/**
+ * How a requested size reached the image — reported so that "this environment cannot resize" is a
+ * 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.
+ * - `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.
+ * - `thumbnail`: a pre-generated Jahia thumbnail matched the request. Real resizing, and the only
+ * channel that works on a plain instance with no CDN in front of it.
+ * - `query`: the size travels as `?w=` / `?h=` query parameters. Honoured by the Media Optimization
+ * (Cloudimage) proxy in live mode on Jahia Cloud, and ignored — same bytes — by the plain file
+ * servlet, because
+ * {@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";
+
+/** A URL, plus how the requested size actually reached the image. */
+export interface ImageUrl {
+ url: string;
+ channel: ImageResizeChannel;
+ /** The width the URL actually asks for, clamped to the intrinsic width. */
+ width?: number;
+}
+
+/**
+ * Widths of Jahia's pre-generated thumbnails, in image pixels.
+ *
+ * Every Jahia instance generates these for uploaded images without any additional module, so they
+ * are the one resize channel available offline. Keep them in ascending order.
+ */
+const THUMBNAILS: readonly { name: string; width: number }[] = [
+ { name: "thumbnail", width: 150 },
+ { name: "thumbnail2", width: 350 },
+];
+
+/**
+ * True when the node lives in the default JCR provider (a local `/files` asset), false when it is
+ * mounted from an external provider.
+ */
+const isDefaultProvider = (node: JCRNodeWrapper): boolean => {
+ try {
+ return node.getProvider().isDefault();
+ } catch {
+ // A provider that cannot be read is treated as external: the provider channel builds a URL
+ // through the decorator, which is a no-op for the default provider, whereas the opposite
+ // mistake would send a DAM node down a query-string path its CDN never sees.
+ return false;
+ }
+};
+
+/** 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;
+
+ try {
+ return node.getThumbnailUrl(thumbnail.name) || undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+/**
+ * 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.
+ *
+ * @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.
+ * @returns The URL and the channel that carried the size.
+ * @see {@link getImageProps} to build a full set of `
` props, including `srcSet`.
+ */
+export function buildImageUrl(
+ node: JCRNodeWrapper,
+ size?: { width?: number; height?: number },
+ options?: {
+ meta?: ImageMeta;
+ context?: { renderContext?: RenderContext; currentResource?: Resource };
+ },
+): ImageUrl {
+ const meta = options?.meta ?? readImageMeta(node);
+ const context = options?.context;
+ const original = (): ImageUrl => ({
+ url: buildNodeUrl(node, {}, context),
+ channel: "original",
+ });
+
+ // A vector is resolution-independent: resizing it server-side is meaningless
+ if (meta.vector) return original();
+
+ const width =
+ size?.width === undefined ? undefined : clampToIntrinsic(size.width, meta.intrinsicWidth);
+ const height =
+ size?.height === undefined ? undefined : clampToIntrinsic(size.height, meta.intrinsicHeight);
+
+ const noopWidth = width === undefined || width === meta.intrinsicWidth;
+ const noopHeight = height === undefined || height === meta.intrinsicHeight;
+ if (noopWidth && noopHeight) return original();
+
+ // An external provider's decorator signs and transforms; `args` reach node.getUrl(List)
+ if (!isDefaultProvider(node)) {
+ return {
+ url: buildNodeUrl(
+ node,
+ {
+ args: {
+ ...(noopWidth ? {} : { w: width! }),
+ ...(noopHeight ? {} : { h: height! }),
+ },
+ },
+ context,
+ ),
+ channel: "provider",
+ width,
+ };
+ }
+
+ // A pre-generated thumbnail is a real, offline resize — prefer it over a hint nothing may honour
+ if (noopHeight && width !== undefined) {
+ const thumbnail = matchingThumbnail(node, width);
+ if (thumbnail) return { url: thumbnail, channel: "thumbnail", width };
+ }
+
+ return {
+ url: buildNodeUrl(
+ node,
+ {
+ parameters: {
+ ...(noopWidth ? {} : { w: String(width) }),
+ ...(noopHeight ? {} : { h: String(height) }),
+ },
+ },
+ context,
+ ),
+ channel: "query",
+ width,
+ };
+}
+
+/** The widths Jahia can resize to offline, exposed for docs and tests. */
+export const THUMBNAIL_WIDTHS: readonly number[] = THUMBNAILS.map(({ width }) => width);
diff --git a/javascript-modules-library/src/utils/image/getImageProps.ts b/javascript-modules-library/src/utils/image/getImageProps.ts
new file mode 100644
index 00000000..4149d48b
--- /dev/null
+++ b/javascript-modules-library/src/utils/image/getImageProps.ts
@@ -0,0 +1,222 @@
+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 { 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.
+ *
+ * - `constrained` (default): the image is at most `width` CSS pixels wide and shrinks with the
+ * viewport below that — the common case for content in a column.
+ * - `fixed`: the image is always `width` CSS pixels wide (an avatar, a logo slot, a card thumbnail in
+ * a fixed grid).
+ * - `full-width`: the image always spans the viewport (a hero).
+ */
+export type ImageLayout = "constrained" | "fixed" | "full-width";
+
+/**
+ * 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.
+ *
+ * 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.
+ */
+export const DEFAULT_BREAKPOINTS: readonly number[] = [320, 640, 960, 1280, 1920, 2560];
+
+/**
+ * `
` 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 {
+ src: string;
+ srcSet?: string;
+ sizes?: string;
+ /** Intrinsic width in image pixels, when Jahia extracted it. Reserves space, preventing CLS. */
+ width?: number;
+ /** Intrinsic height in image pixels, when Jahia extracted it. */
+ height?: number;
+ /**
+ * Alternative text. Required — an image that carries no information for a screen reader is
+ * declared with `alt=""`, explicitly.
+ */
+ alt: string;
+}
+
+export interface ImageOptions {
+ /** Alternative text; `""` declares the image decorative. */
+ alt: string;
+ /**
+ * How the image occupies its slot.
+ *
+ * @default "constrained"
+ */
+ layout?: ImageLayout;
+ /** The slot width in CSS pixels. Required by `constrained` and `fixed`. */
+ width?: number;
+ /** Explicit candidate widths in image pixels. Escape hatch: prefer `layout` + `width`. */
+ widths?: number[];
+ /** Explicit `sizes` attribute. Escape hatch: prefer `layout` + `width`. */
+ sizes?: string;
+ /** Candidate ladder used by `constrained` and `full-width`. */
+ breakpoints?: readonly number[];
+ /**
+ * Register a render cache dependency on the image node, so that editing the image flushes the
+ * fragments that display it. Turn it off only when the caller registers it itself.
+ *
+ * @default true
+ */
+ cacheDependency?: boolean;
+}
+
+/**
+ * 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.
+ *
+ * @see {@link https://github.com/Jahia/jahia/issues/23}
+ */
+const srcSetSafe = (url: string) => url.replaceAll(",", "%2C");
+
+/** The candidate widths a layout asks for, before clamping. */
+const candidateWidths = (
+ layout: ImageLayout,
+ width: number | undefined,
+ breakpoints: readonly number[],
+): number[] => {
+ if (layout === "full-width") return [...breakpoints];
+
+ if (width === undefined) {
+ throw new Error(
+ `getImageProps: layout "${layout}" needs a width (the slot width in CSS pixels). ` +
+ `Use layout "full-width" for an image that always spans the viewport.`,
+ );
+ }
+
+ // Two device-pixel ratios cover the realistic range; a 3x file is rarely worth its bytes
+ const densities = [width, width * 2];
+ if (layout === "fixed") return densities;
+
+ // Constrained: the slot shrinks with the viewport, so smaller files are useful too
+ return [...breakpoints.filter((candidate) => candidate < width), ...densities];
+};
+
+/** The `sizes` attribute a layout implies. */
+const derivedSizes = (layout: ImageLayout, width: number | undefined): string => {
+ switch (layout) {
+ case "full-width":
+ return "100vw";
+ case "fixed":
+ return `${width}px`;
+ case "constrained":
+ return `(min-width: ${width}px) ${width}px, 100vw`;
+ }
+};
+
+/**
+ * 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.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```;
+ *
+ * @param node - The file node holding the image.
+ * @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.
+ * @see {@link JImage} for the component that renders these props.
+ */
+export function getImageProps(
+ node: JCRNodeWrapper,
+ options: ImageOptions,
+ context?: { renderContext?: RenderContext; currentResource?: Resource },
+): ImageProps {
+ const {
+ alt,
+ layout = "constrained",
+ width,
+ widths,
+ sizes,
+ breakpoints = DEFAULT_BREAKPOINTS,
+ cacheDependency = true,
+ } = options;
+
+ const meta = readImageMeta(node);
+ const renderContext = context?.renderContext;
+ if (cacheDependency && renderContext) {
+ server.render.addCacheDependency({ node }, renderContext);
+ }
+
+ const base = {
+ alt: alt.trim(),
+ width: meta.intrinsicWidth,
+ height: meta.intrinsicHeight,
+ };
+
+ // A vector needs no candidates: one resolution-independent file serves every slot
+ if (meta.vector) {
+ return { ...base, src: buildImageUrl(node, undefined, { meta, context }).url };
+ }
+
+ const requested = (widths ?? candidateWidths(layout, width, breakpoints))
+ .filter((candidate) => candidate > 0)
+ .map((candidate) => clampToIntrinsic(candidate, meta.intrinsicWidth))
+ .sort((a, b) => a - b);
+
+ // The original joins the ladder only when it is close to the largest candidate: an 8000px master
+ // must never be served into a 1536px slot, but a 2000px original is a useful top candidate.
+ const largest = requested.at(-1);
+ if (meta.intrinsicWidth && largest && meta.intrinsicWidth <= 2 * largest) {
+ requested.push(meta.intrinsicWidth);
+ }
+
+ // One candidate per distinct URL, keeping the SMALLEST width that produced it. A provider may
+ // collapse several requested widths onto one rendition; under-claiming its width makes the
+ // browser climb to a bigger candidate rather than paint an upscaled one.
+ const widthByUrl = new Map();
+ let ignoredResize = false;
+ for (const candidate of requested) {
+ const { url, channel } = buildImageUrl(node, { width: candidate }, { meta, context });
+ if (channel === "query") ignoredResize = true;
+ if (!widthByUrl.has(url)) widthByUrl.set(url, candidate);
+ }
+
+ if (ignoredResize) warnIgnoredResize(node);
+
+ const [smallest] = [...widthByUrl.keys()];
+ return {
+ ...base,
+ src: smallest ?? buildImageUrl(node, undefined, { meta, context }).url,
+ srcSet:
+ widthByUrl.size > 1
+ ? [...widthByUrl].map(([url, candidate]) => `${srcSetSafe(url)} ${candidate}w`).join(", ")
+ : undefined,
+ sizes: widthByUrl.size > 1 ? (sizes ?? derivedSizes(layout, width)) : sizes,
+ };
+}
+
+/**
+ * Which channel each candidate of an image would use — the answer to "why does resizing do nothing
+ * on my machine?". A plain instance reports `query` for anything but the pre-generated thumbnails,
+ * meaning the URLs carry a size hint that only Media Optimization (on Jahia Cloud, in live mode)
+ * interprets.
+ *
+ * @param node - The file node holding the image.
+ * @param width - The width to inspect.
+ * @returns The channel that would carry that width.
+ * @see {@link ImageResizeChannel}
+ */
+export function inspectImageChannel(node: JCRNodeWrapper, width: number): ImageResizeChannel {
+ return buildImageUrl(node, { width }).channel;
+}
diff --git a/javascript-modules-library/src/utils/image/image.spec.ts b/javascript-modules-library/src/utils/image/image.spec.ts
new file mode 100644
index 00000000..4ec088d6
--- /dev/null
+++ b/javascript-modules-library/src/utils/image/image.spec.ts
@@ -0,0 +1,392 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { JCRNodeWrapper } from "org.jahia.services.content";
+
+// `buildNodeUrl` reaches into the Jahia render context, which only exists inside the engine. The
+// mock reproduces the two channels it offers: `parameters` become a query string (the default
+// provider, honoured by Media Optimization), and `args` go through node.getUrl(["w:600"]), which a
+// DAM decorator turns into a signed, transformed URL.
+vi.mock("../urlBuilder/urlBuilder.js", () => ({
+ buildNodeUrl: (
+ node: { url: string; getUrl: (params: string[]) => string },
+ config?: { parameters?: Record; args?: Record },
+ ) => {
+ if (config?.args) {
+ return node.getUrl(Object.entries(config.args).map(([key, value]) => `${key}:${value}`));
+ }
+
+ return config?.parameters ? `${node.url}?${new URLSearchParams(config.parameters)}` : node.url;
+ },
+ buildModuleFileUrl: (path: string) => `/modules/test${path}`,
+}));
+
+const { buildImageUrl } = await import("./buildImageUrl.js");
+const { getImageProps, 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. */
+const imageNode = ({
+ url = "/files/photo.jpg",
+ path,
+ mimeType = "image/jpeg",
+ width,
+ height,
+ defaultProvider = true,
+ thumbnails = ["thumbnail", "thumbnail2"],
+ getUrl,
+}: {
+ url?: string;
+ path?: string;
+ mimeType?: string;
+ width?: number;
+ height?: number;
+ defaultProvider?: boolean;
+ thumbnails?: string[];
+ getUrl?: (params: string[]) => string;
+} = {}) =>
+ ({
+ url,
+ // A real node always reports a path; omitting it models the one that cannot be read
+ getPath: () => {
+ if (path === undefined) throw new Error("no path");
+ return path;
+ },
+ getProvider: () => ({ isDefault: () => defaultProvider, getKey: () => "test" }),
+ // A DAM decorator signs the transformed URL; the default provider discards these params
+ getUrl: getUrl ?? ((params: string[]) => `${url}#signed(${params.join(",")})`),
+ getThumbnailUrl: (name: string) => {
+ if (!thumbnails.includes(name)) throw new Error(`no thumbnail ${name}`);
+ return `${url}?t=${name}`;
+ },
+ getDisplayableName: () => "photo.jpg",
+ getNode: (child: string) =>
+ child === "jcr:content"
+ ? { getPropertyAsString: (p: string) => (p === "jcr:mimeType" ? mimeType : "") }
+ : null,
+ getProperty: (property: string) => {
+ const value = property === "j:width" ? width : property === "j:height" ? height : undefined;
+ // A JCR node throws PathNotFoundException on a property it does not have
+ if (value === undefined) throw new Error(`no such property: ${property}`);
+ return { getLong: () => value };
+ },
+ }) as unknown as JCRNodeWrapper;
+
+describe("readImageMeta", () => {
+ it("reads the intrinsic dimensions of a raster image", () => {
+ expect(readImageMeta(imageNode({ width: 2000, height: 1000 }))).toEqual({
+ vector: false,
+ intrinsicWidth: 2000,
+ intrinsicHeight: 1000,
+ });
+ });
+
+ it("flags a vector and does not look for dimensions", () => {
+ expect(readImageMeta(imageNode({ mimeType: "image/svg+xml", width: 100 }))).toEqual({
+ vector: true,
+ });
+ });
+
+ it("tolerates missing dimensions and a missing mime type", () => {
+ expect(readImageMeta(imageNode())).toEqual({
+ vector: false,
+ intrinsicWidth: undefined,
+ intrinsicHeight: undefined,
+ });
+ });
+});
+
+describe("buildImageUrl", () => {
+ it("returns the original when no size is requested", () => {
+ const node = imageNode({ width: 2000 });
+ expect(buildImageUrl(node)).toEqual({ url: "/files/photo.jpg", channel: "original" });
+ });
+
+ it("returns the original when the resize would be a no-op", () => {
+ const node = imageNode({ width: 2000 });
+ expect(buildImageUrl(node, { width: 2000 }).channel).toBe("original");
+ // Clamped to the intrinsic width first, so an oversized request is also a no-op
+ expect(buildImageUrl(node, { width: 4000 }).channel).toBe("original");
+ });
+
+ it("never resizes a vector", () => {
+ const node = imageNode({ url: "/files/logo.svg", mimeType: "image/svg+xml" });
+ expect(buildImageUrl(node, { width: 600 })).toEqual({
+ url: "/files/logo.svg",
+ channel: "original",
+ });
+ });
+
+ it("prefers a pre-generated thumbnail, the only resize a plain instance performs", () => {
+ const node = imageNode({ width: 2000 });
+ expect(buildImageUrl(node, { width: 150 })).toEqual({
+ url: "/files/photo.jpg?t=thumbnail",
+ channel: "thumbnail",
+ width: 150,
+ });
+ });
+
+ it("falls back to query parameters for a width no thumbnail covers", () => {
+ const node = imageNode({ width: 2000 });
+ expect(buildImageUrl(node, { width: 600 })).toEqual({
+ url: "/files/photo.jpg?w=600",
+ channel: "query",
+ width: 600,
+ });
+ });
+
+ it("routes an external provider through node.getUrl, so its decorator signs the variant", () => {
+ const node = imageNode({
+ url: "https://dam.example/a.jpg",
+ width: 4000,
+ defaultProvider: false,
+ });
+ expect(buildImageUrl(node, { width: 600 })).toEqual({
+ url: "https://dam.example/a.jpg#signed(w:600)",
+ channel: "provider",
+ width: 600,
+ });
+ });
+
+ it("drops the axis that would be a no-op", () => {
+ const node = imageNode({ width: 2000, height: 1000 });
+ expect(buildImageUrl(node, { width: 600, height: 1000 }).url).toBe("/files/photo.jpg?w=600");
+ });
+});
+
+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/,
+ );
+ });
+
+ it("derives candidates and sizes for a fixed slot", () => {
+ const props = getImageProps(imageNode({ width: 2000, height: 1000 }), {
+ alt: "A terrace",
+ layout: "fixed",
+ width: 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");
+ expect(props.sizes).toBe("300px");
+ expect(props).toMatchObject({ src: "/files/photo.jpg?w=300", width: 2000, height: 1000 });
+ });
+
+ it("adds smaller candidates for a constrained slot that can shrink", () => {
+ const props = getImageProps(imageNode({ width: 4000 }), {
+ alt: "A terrace",
+ width: 960,
+ });
+ 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",
+ );
+ expect(props.sizes).toBe("(min-width: 960px) 960px, 100vw");
+ });
+
+ it("uses the breakpoint ladder for a full-width hero", () => {
+ const props = getImageProps(imageNode({ width: 4000 }), {
+ alt: "A terrace",
+ layout: "full-width",
+ });
+ expect(props.sizes).toBe("100vw");
+ for (const breakpoint of DEFAULT_BREAKPOINTS) {
+ expect(props.srcSet).toContain(`${breakpoint}w`);
+ }
+ });
+
+ 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,
+ ).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,
+ ).not.toContain("8000w");
+ });
+
+ it("keeps the smallest width when a provider collapses several onto one rendition", () => {
+ // A DAM serving fixed renditions: every request snaps up to 320 / 1024 / 2048
+ const node = imageNode({
+ url: "https://dam.example/a.jpg",
+ width: 2048,
+ defaultProvider: false,
+ getUrl: (params) => {
+ const requested = Number(params[0].split(":")[1]);
+ const rendition = [320, 1024, 2048].find((size) => size >= requested) ?? 2048;
+ return `https://dam.example/a.jpg#rendition(${rendition})`;
+ },
+ });
+ const props = getImageProps(node, { alt: "", layout: "fixed", width: 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(
+ "https://dam.example/a.jpg#rendition(1024) 600w, " +
+ "https://dam.example/a.jpg#rendition(2048) 1200w, " +
+ // The 2048px original is within 2x of the largest request, so it earns a place too
+ "https://dam.example/a.jpg 2048w",
+ );
+ });
+
+ it("percent-encodes commas inside srcSet, which Jahia's rewriter splits on", () => {
+ const node = imageNode({
+ url: "https://cdn.example/image/upload/v1/a.jpg",
+ width: 1200,
+ defaultProvider: false,
+ // A Cloudinary-style decorator puts comma-separated transformations in the path
+ getUrl: (params) =>
+ `https://cdn.example/image/upload/f_auto,${params[0].replace(":", "_")}/v1/a.jpg`,
+ });
+ const props = getImageProps(node, { alt: "", layout: "fixed", width: 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
+ expect(props.src).toBe("https://cdn.example/image/upload/f_auto,w_600/v1/a.jpg");
+ });
+
+ it("serves one resolution-independent file for a vector, with no candidates", () => {
+ const props = getImageProps(imageNode({ url: "/files/logo.svg", mimeType: "image/svg+xml" }), {
+ alt: "Acme",
+ layout: "fixed",
+ width: 100,
+ });
+ expect(props).toEqual({
+ src: "/files/logo.svg",
+ alt: "Acme",
+ width: undefined,
+ height: undefined,
+ });
+ });
+
+ it("collapses to a single candidate when the original is smaller than the slot", () => {
+ const props = getImageProps(imageNode({ width: 200 }), {
+ alt: "",
+ layout: "fixed",
+ width: 300,
+ });
+ // Every candidate clamps to 200, which is a no-op resize: one original URL, no srcSet
+ expect(props.src).toBe("/files/photo.jpg");
+ expect(props.srcSet).toBeUndefined();
+ });
+
+ it("takes explicit widths and sizes as an escape hatch", () => {
+ const props = getImageProps(imageNode({ width: 2000 }), {
+ alt: "",
+ widths: [400, 800],
+ sizes: "50vw",
+ });
+ expect(props.srcSet).toBe("/files/photo.jpg?w=400 400w, /files/photo.jpg?w=800 800w");
+ expect(props.sizes).toBe("50vw");
+ });
+
+ 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(
+ "A terrace",
+ );
+ expect(getImageProps(node, { alt: "", layout: "fixed", width: 300 }).alt).toBe("");
+ });
+});
+
+describe("the ignored-resize warning", () => {
+ /** The engine injects `server` as a global; a test provides only the part under test. */
+ const stubDevelopmentMode = (developmentMode: boolean) => {
+ Reflect.set(globalThis, "server", {
+ config: { isDevelopmentMode: () => developmentMode },
+ });
+ };
+
+ /**
+ * 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;
+ beforeEach(async () => {
+ vi.resetModules();
+ ({ getImageProps: freshImageProps } = await import("./getImageProps.js"));
+ });
+
+ /** A slot of 600 on a 2000px original: candidates no thumbnail covers, so `?w=` carries them. */
+ const slot = { alt: "", layout: "fixed", width: 600 } as const;
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ Reflect.deleteProperty(globalThis, "server");
+ });
+
+ it("names the node and points at the guide", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ stubDevelopmentMode(true);
+
+ freshImageProps(imageNode({ path: "/sites/test/files/hinted.jpg", width: 2000 }), slot);
+
+ expect(warn).toHaveBeenCalledTimes(1);
+ expect(warn.mock.calls[0][0]).toContain("/sites/test/files/hinted.jpg");
+ expect(warn.mock.calls[0][0]).toContain("8-images/README.md");
+ });
+
+ it("says nothing in production", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ stubDevelopmentMode(false);
+
+ freshImageProps(imageNode({ path: "/sites/test/files/production.jpg", width: 2000 }), slot);
+
+ expect(warn).not.toHaveBeenCalled();
+ });
+
+ it("says nothing when there is no server bridge to ask", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ expect(() =>
+ freshImageProps(imageNode({ path: "/sites/test/files/no-bridge.jpg", width: 2000 }), slot),
+ ).not.toThrow();
+ expect(warn).not.toHaveBeenCalled();
+ });
+
+ it("says nothing about the channels that do resize", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ stubDevelopmentMode(true);
+
+ // A pre-generated thumbnail width: a real resize on any instance
+ freshImageProps(imageNode({ path: "/sites/test/files/thumb.jpg", width: 2000 }), {
+ alt: "",
+ widths: [150],
+ });
+ // An external provider, whose decorator signs a transformed URL
+ freshImageProps(
+ imageNode({ path: "/sites/test/files/dam.jpg", width: 4000, defaultProvider: false }),
+ slot,
+ );
+ // Nothing to resize: every candidate clamps to the smaller original
+ freshImageProps(imageNode({ path: "/sites/test/files/small.jpg", width: 200 }), slot);
+ // A vector needs no candidates at all
+ freshImageProps(
+ imageNode({ path: "/sites/test/files/logo.svg", mimeType: "image/svg+xml" }),
+ slot,
+ );
+
+ expect(warn).not.toHaveBeenCalled();
+ });
+
+ it("warns once for the instance, not per image or per render", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ stubDevelopmentMode(true);
+
+ const node = imageNode({ path: "/sites/test/files/repeated.jpg", width: 2000 });
+ freshImageProps(node, slot);
+ // Re-rendering the same image says nothing new
+ freshImageProps(node, slot);
+ // Neither does a different image: the instance, not the asset, is what ignores the parameters
+ freshImageProps(imageNode({ path: "/sites/test/files/another.jpg", width: 2000 }), slot);
+
+ expect(warn).toHaveBeenCalledTimes(1);
+ });
+
+ it("still renders when the node cannot report its path", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ stubDevelopmentMode(true);
+
+ expect(() => freshImageProps(imageNode({ width: 2000 }), slot)).not.toThrow();
+ expect(warn).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/javascript-modules-library/src/utils/image/imageMeta.ts b/javascript-modules-library/src/utils/image/imageMeta.ts
new file mode 100644
index 00000000..9e23f6c1
--- /dev/null
+++ b/javascript-modules-library/src/utils/image/imageMeta.ts
@@ -0,0 +1,61 @@
+import type { JCRNodeWrapper } from "org.jahia.services.content";
+
+/** What we can learn about an image asset before building any URL. */
+export interface ImageMeta {
+ /**
+ * Vector images (SVG and friends) are never resized: they have no meaningful intrinsic pixel
+ * size, and a `srcSet` of one URL says nothing.
+ */
+ vector: boolean;
+ /** `j:width` in image pixels, when Jahia extracted it. */
+ intrinsicWidth?: number;
+ /** `j:height` in image pixels, when Jahia extracted it. */
+ intrinsicHeight?: number;
+}
+
+/**
+ * A JCR node throws on a property it does not carry, and `j:width` / `j:height` are only present
+ * once Jahia's image extractor has run.
+ */
+const readPositiveLong = (node: JCRNodeWrapper, property: string): number | undefined => {
+ try {
+ const value = Number(node.getProperty(property)?.getLong());
+ return value > 0 ? value : undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+/** Reads the mime type of a file node, tolerating a missing `jcr:content` child. */
+const readMimeType = (node: JCRNodeWrapper): string => {
+ try {
+ return node.getNode("jcr:content")?.getPropertyAsString("jcr:mimeType") ?? "";
+ } catch {
+ return "";
+ }
+};
+
+/**
+ * Reads the mime type and, for a raster image, the intrinsic dimensions of a file node.
+ *
+ * @param node - A file node holding an image.
+ * @returns The metadata needed to size the image, with unknown values left undefined.
+ */
+export function readImageMeta(node: JCRNodeWrapper): ImageMeta {
+ const mimeType = readMimeType(node);
+
+ // image/vnd.* covers vendor vector formats (Adobe Illustrator, DXF…)
+ if (mimeType.startsWith("image/svg") || mimeType.startsWith("image/vnd")) {
+ return { vector: true };
+ }
+
+ return {
+ vector: false,
+ intrinsicWidth: readPositiveLong(node, "j:width"),
+ intrinsicHeight: readPositiveLong(node, "j:height"),
+ };
+}
+
+/** Never requests more than the intrinsic size, when it is known. */
+export const clampToIntrinsic = (requested: number, intrinsic?: number): number =>
+ intrinsic ? Math.min(requested, intrinsic) : requested;
diff --git a/javascript-modules-library/src/utils/image/warnIgnoredResize.ts b/javascript-modules-library/src/utils/image/warnIgnoredResize.ts
new file mode 100644
index 00000000..cb8b995b
--- /dev/null
+++ b/javascript-modules-library/src/utils/image/warnIgnoredResize.ts
@@ -0,0 +1,56 @@
+import type { JCRNodeWrapper } from "org.jahia.services.content";
+
+/**
+ * Whether the instance has already been told. What the warning reports is a property of the
+ * instance, not of the image — if these parameters are ignored for one asset they are ignored for
+ * every one — so one line per engine lifetime says everything a second would.
+ */
+let reported = false;
+
+/**
+ * True on a development instance.
+ *
+ * Every part of this call can be missing — the whole `server` bridge outside the engine, the method
+ * on an engine older than it — and a diagnostic that cannot tell must stay quiet rather than fail.
+ */
+const isDevelopmentMode = (): boolean => {
+ try {
+ return server.config.isDevelopmentMode();
+ } catch {
+ return false;
+ }
+};
+
+/** How the message names the image it uses as its example. */
+const identify = (node: JCRNodeWrapper): string => {
+ try {
+ return node.getPath() || "an image";
+ } catch {
+ return "an image";
+ }
+};
+
+/**
+ * Warns, once, that the widths of an image travel as `?w=` parameters this instance most likely
+ * ignores.
+ *
+ * Nothing else reports that trap: the markup is correct, only the bytes never shrink. The warning
+ * is emitted in development mode only — a production instance pays nothing — and stays silent about
+ * anything it cannot read, because a diagnostic that breaks a render is worse than no diagnostic.
+ *
+ * @param node - The file node whose candidates landed on the `query` channel.
+ * @see {@link ImageResizeChannel} for what each channel does with a requested size.
+ */
+export function warnIgnoredResize(node: JCRNodeWrapper): void {
+ if (!isDevelopmentMode()) return;
+
+ if (reported) return;
+ reported = true;
+
+ console.warn(
+ `getImageProps: the ?w= candidates of ${identify(node)} — and of every other image on this ` +
+ `instance — return the original bytes, because only Media Optimization reads those ` +
+ `parameters. Expected on an instance without it; the markup is still correct. ` +
+ `See docs/2-guides/8-images/README.md, "What actually resizes the image, and where".`,
+ );
+}
diff --git a/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts b/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts
index c1777198..b12524b1 100644
--- a/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts
+++ b/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts
@@ -16,6 +16,11 @@ function appendParameters(url: string, parameters: Record): stri
/**
* Generate a Jahia url for the provided node.
*
+ * For an image, prefer {@link buildImageUrl} (or the `JImage` component): it clamps the requested
+ * size to the original, picks the resize channel the node's provider honours, and can use the
+ * thumbnails Jahia pre-generates (`node.getThumbnailUrl("thumbnail")`), which are the only variants
+ * a plain instance serves.
+ *
* @returns The final URL
*/
export function buildNodeUrl(
@@ -45,7 +50,16 @@ export function buildNodeUrl(
| {
/** The query string parameters to append to the URL */
parameters?: Record;
- /** Additional arguments used for building the URL, through `node.getUrl` overloads. */
+ /**
+ * 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
+ * URL.
+ *
+ * **The default provider ignores them:** core's `JCRNodeWrapperImpl.getUrl(List)` discards
+ * its parameters, so on a local `/files` asset these produce the plain URL. To resize such
+ * an image, use {@link buildImageUrl}, which picks the channel the asset's provider actually
+ * honours.
+ */
args?: Record;
},
context?: {
diff --git a/samples/hydrogen/src/components/BlogPost/default.server.tsx b/samples/hydrogen/src/components/BlogPost/default.server.tsx
index 304d4c69..ef000ac0 100644
--- a/samples/hydrogen/src/components/BlogPost/default.server.tsx
+++ b/samples/hydrogen/src/components/BlogPost/default.server.tsx
@@ -1,4 +1,4 @@
-import { buildNodeUrl, jahiaComponent } from "@jahia/javascript-modules-library";
+import { buildNodeUrl, JImage, jahiaComponent } from "@jahia/javascript-modules-library";
import type { Props } from "./types.js";
import classes from "./component.module.css";
@@ -14,7 +14,7 @@ jahiaComponent(
) => {
return (
-
+
diff --git a/yarn.lock b/yarn.lock
index 95e39e32..096b8a87 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -479,6 +479,7 @@ __metadata:
publint: "npm:^0.3.16"
react-i18next: "npm:^16.5.0"
typescript: "npm:^5.9.3"
+ vitest: "npm:^4.1.11"
peerDependencies:
"@graphql-typed-document-node/core": ^3.2.0
graphql: "*"
@@ -609,6 +610,13 @@ __metadata:
languageName: node
linkType: hard
+"@oxc-project/types@npm:=0.146.0":
+ version: 0.146.0
+ resolution: "@oxc-project/types@npm:0.146.0"
+ checksum: 10c0/15e99d1d4d9233244262779b6e3bbaf7f11a4e62b57c21ef3755acbec469cb90cd468548103fec282af649096e9c6389fd5221c28b268579061352c49b29f4c8
+ languageName: node
+ linkType: hard
+
"@parcel/watcher-android-arm64@npm:2.5.1":
version: 2.5.1
resolution: "@parcel/watcher-android-arm64@npm:2.5.1"
@@ -776,6 +784,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-android-arm-eabi@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-android-arm-eabi@npm:1.2.5"
+ conditions: os=android & cpu=arm
+ languageName: node
+ linkType: hard
+
"@rolldown/binding-android-arm64@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-android-arm64@npm:1.0.3"
@@ -783,6 +798,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-android-arm64@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-android-arm64@npm:1.2.5"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@rolldown/binding-darwin-arm64@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-darwin-arm64@npm:1.0.3"
@@ -790,6 +812,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-darwin-arm64@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-darwin-arm64@npm:1.2.5"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@rolldown/binding-darwin-x64@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-darwin-x64@npm:1.0.3"
@@ -797,6 +826,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-darwin-x64@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-darwin-x64@npm:1.2.5"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
"@rolldown/binding-freebsd-x64@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-freebsd-x64@npm:1.0.3"
@@ -804,6 +840,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-freebsd-x64@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-freebsd-x64@npm:1.2.5"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.3"
@@ -811,6 +854,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-linux-arm-gnueabihf@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.2.5"
+ conditions: os=linux & cpu=arm
+ languageName: node
+ linkType: hard
+
"@rolldown/binding-linux-arm64-gnu@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.3"
@@ -818,6 +868,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-linux-arm64-gnu@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.2.5"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"@rolldown/binding-linux-arm64-musl@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.3"
@@ -825,6 +882,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-linux-arm64-musl@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-linux-arm64-musl@npm:1.2.5"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
"@rolldown/binding-linux-ppc64-gnu@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.3"
@@ -832,6 +896,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-linux-ppc64-gnu@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.2.5"
+ conditions: os=linux & cpu=ppc64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"@rolldown/binding-linux-s390x-gnu@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.3"
@@ -839,6 +910,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-linux-s390x-gnu@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.2.5"
+ conditions: os=linux & cpu=s390x & libc=glibc
+ languageName: node
+ linkType: hard
+
"@rolldown/binding-linux-x64-gnu@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.3"
@@ -846,6 +924,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-linux-x64-gnu@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-linux-x64-gnu@npm:1.2.5"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"@rolldown/binding-linux-x64-musl@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.3"
@@ -853,6 +938,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-linux-x64-musl@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-linux-x64-musl@npm:1.2.5"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
"@rolldown/binding-openharmony-arm64@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.3"
@@ -860,6 +952,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-openharmony-arm64@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-openharmony-arm64@npm:1.2.5"
+ conditions: os=openharmony & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@rolldown/binding-wasm32-wasi@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.3"
@@ -878,6 +977,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-win32-arm64-msvc@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.2.5"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@rolldown/binding-win32-x64-msvc@npm:1.0.3":
version: 1.0.3
resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.3"
@@ -885,6 +991,13 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-win32-x64-msvc@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@rolldown/binding-win32-x64-msvc@npm:1.2.5"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
"@rolldown/pluginutils@npm:^1.0.0":
version: 1.0.1
resolution: "@rolldown/pluginutils@npm:1.0.1"
@@ -908,6 +1021,13 @@ __metadata:
languageName: node
linkType: hard
+"@standard-schema/spec@npm:^1.1.0":
+ version: 1.1.0
+ resolution: "@standard-schema/spec@npm:1.1.0"
+ checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526
+ languageName: node
+ linkType: hard
+
"@tybys/wasm-util@npm:^0.10.1":
version: 0.10.2
resolution: "@tybys/wasm-util@npm:0.10.2"
@@ -924,6 +1044,16 @@ __metadata:
languageName: node
linkType: hard
+"@types/chai@npm:^5.2.2":
+ version: 5.2.3
+ resolution: "@types/chai@npm:5.2.3"
+ dependencies:
+ "@types/deep-eql": "npm:*"
+ assertion-error: "npm:^2.0.1"
+ checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f
+ languageName: node
+ linkType: hard
+
"@types/debug@npm:^4.0.0":
version: 4.1.12
resolution: "@types/debug@npm:4.1.12"
@@ -933,6 +1063,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/deep-eql@npm:*":
+ version: 4.0.2
+ resolution: "@types/deep-eql@npm:4.0.2"
+ checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844
+ languageName: node
+ linkType: hard
+
"@types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6":
version: 1.0.8
resolution: "@types/estree@npm:1.0.8"
@@ -1256,6 +1393,88 @@ __metadata:
languageName: node
linkType: hard
+"@vitest/expect@npm:4.1.11":
+ version: 4.1.11
+ resolution: "@vitest/expect@npm:4.1.11"
+ dependencies:
+ "@standard-schema/spec": "npm:^1.1.0"
+ "@types/chai": "npm:^5.2.2"
+ "@vitest/spy": "npm:4.1.11"
+ "@vitest/utils": "npm:4.1.11"
+ chai: "npm:^6.2.2"
+ tinyrainbow: "npm:^3.1.0"
+ checksum: 10c0/0aa5e0973aca93a58cbdc3041c6bfed5897e976124965203b96a23b811f4ca403590c7eb15802c8d8366ec27a1db0682451e8a91c4d06617636de262baf86e4b
+ languageName: node
+ linkType: hard
+
+"@vitest/mocker@npm:4.1.11":
+ version: 4.1.11
+ resolution: "@vitest/mocker@npm:4.1.11"
+ dependencies:
+ "@vitest/spy": "npm:4.1.11"
+ estree-walker: "npm:^3.0.3"
+ magic-string: "npm:^0.30.21"
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+ checksum: 10c0/3111ea34bd5046f6c70bbd67cf8b89608ee8c41cb27ab2bd61d42f4b68810e9ea16a9a757a71bc254c105f73b407d00ebb6bab1ab0f7f5cdcc9d7d16602a3933
+ languageName: node
+ linkType: hard
+
+"@vitest/pretty-format@npm:4.1.11":
+ version: 4.1.11
+ resolution: "@vitest/pretty-format@npm:4.1.11"
+ dependencies:
+ tinyrainbow: "npm:^3.1.0"
+ checksum: 10c0/ad32525c73807c0b72f38dc29bc51fd5a17879dc650f37995a9c5adbb8526e15f787691f76aa8768448ec7ed5bf5ba2b12329eac8fda1428b4d6b8c036390f71
+ languageName: node
+ linkType: hard
+
+"@vitest/runner@npm:4.1.11":
+ version: 4.1.11
+ resolution: "@vitest/runner@npm:4.1.11"
+ dependencies:
+ "@vitest/utils": "npm:4.1.11"
+ pathe: "npm:^2.0.3"
+ checksum: 10c0/3c782b055e9e688e1785f7c8937bd1669bad1b0e5758cb8b844f918f30a324b1d21e174d46c941ce80ebf37f2b89b55b6d619a675025914a1ece6377b95909e2
+ languageName: node
+ linkType: hard
+
+"@vitest/snapshot@npm:4.1.11":
+ version: 4.1.11
+ resolution: "@vitest/snapshot@npm:4.1.11"
+ dependencies:
+ "@vitest/pretty-format": "npm:4.1.11"
+ "@vitest/utils": "npm:4.1.11"
+ magic-string: "npm:^0.30.21"
+ pathe: "npm:^2.0.3"
+ checksum: 10c0/35d82a7c2a3e4b57529c30387d568d9106b6bf960189214e5d8cb1f5288cb042305c2e5c7b0b2f8cb56a2c814973de0310bc56d72deb79427bea272b6224190c
+ languageName: node
+ linkType: hard
+
+"@vitest/spy@npm:4.1.11":
+ version: 4.1.11
+ resolution: "@vitest/spy@npm:4.1.11"
+ checksum: 10c0/06c68247a8efd21006abe7532fee17f30ba83c8cda3e0b952ef89e278d58058f4b1de69b6bbaa2ed614c568bf4f5769fcc76be42802dedf2bcdd9c0aae601740
+ languageName: node
+ linkType: hard
+
+"@vitest/utils@npm:4.1.11":
+ version: 4.1.11
+ resolution: "@vitest/utils@npm:4.1.11"
+ dependencies:
+ "@vitest/pretty-format": "npm:4.1.11"
+ convert-source-map: "npm:^2.0.0"
+ tinyrainbow: "npm:^3.1.0"
+ checksum: 10c0/a2c1ddc64333458c3e031465c1ee0440a7660fd1b298c54ccbf865ef1e5cf3ebdd6c5c75a5ea235d8a672498b394e7121f992b096f021f45014ab25e9abd1b52
+ languageName: node
+ linkType: hard
+
"abbrev@npm:^2.0.0":
version: 2.0.0
resolution: "abbrev@npm:2.0.0"
@@ -1356,6 +1575,13 @@ __metadata:
languageName: node
linkType: hard
+"assertion-error@npm:^2.0.1":
+ version: 2.0.1
+ resolution: "assertion-error@npm:2.0.1"
+ checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8
+ languageName: node
+ linkType: hard
+
"ast-kit@npm:^3.0.0-beta.1":
version: 3.0.0-beta.1
resolution: "ast-kit@npm:3.0.0-beta.1"
@@ -1480,6 +1706,13 @@ __metadata:
languageName: node
linkType: hard
+"chai@npm:^6.2.2":
+ version: 6.2.2
+ resolution: "chai@npm:6.2.2"
+ checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53
+ languageName: node
+ linkType: hard
+
"chalk@npm:^4.0.0":
version: 4.1.2
resolution: "chalk@npm:4.1.2"
@@ -1564,6 +1797,13 @@ __metadata:
languageName: node
linkType: hard
+"convert-source-map@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "convert-source-map@npm:2.0.0"
+ checksum: 10c0/8f2f7a27a1a011cc6cc88cc4da2d7d0cfa5ee0369508baae3d98c260bb3ac520691464e5bbe4ae7cdf09860c1d69ecc6f70c63c6e7c7f7e3f18ec08484dc7d9b
+ languageName: node
+ linkType: hard
+
"copy-anything@npm:^2.0.1":
version: 2.0.6
resolution: "copy-anything@npm:2.0.6"
@@ -1776,6 +2016,13 @@ __metadata:
languageName: node
linkType: hard
+"es-module-lexer@npm:^2.0.0":
+ version: 2.3.2
+ resolution: "es-module-lexer@npm:2.3.2"
+ checksum: 10c0/5e7389424c43478439f12f9a6aca1750f6f99afa384fc3de329f4a45f152ab156671055008adaafc74960877abf8cc338aeebf6bed8c21297b146fd6eb7a22f8
+ languageName: node
+ linkType: hard
+
"escape-string-regexp@npm:^4.0.0":
version: 4.0.0
resolution: "escape-string-regexp@npm:4.0.0"
@@ -2060,6 +2307,13 @@ __metadata:
languageName: node
linkType: hard
+"expect-type@npm:^1.3.0":
+ version: 1.4.0
+ resolution: "expect-type@npm:1.4.0"
+ checksum: 10c0/d40d76b8570695d36587beb3cc28494da2ca3ec8f04e67f5622ed2d372d850e401a9adef19c6835e1a8173903f157c79540b34c7b3fbd7cd8ce726cc903c57b7
+ languageName: node
+ linkType: hard
+
"exponential-backoff@npm:^3.1.1":
version: 3.1.1
resolution: "exponential-backoff@npm:3.1.1"
@@ -2755,6 +3009,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-android-arm64@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-android-arm64@npm:1.33.0"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
"lightningcss-darwin-arm64@npm:1.32.0":
version: 1.32.0
resolution: "lightningcss-darwin-arm64@npm:1.32.0"
@@ -2762,6 +3023,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-darwin-arm64@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-darwin-arm64@npm:1.33.0"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
"lightningcss-darwin-x64@npm:1.32.0":
version: 1.32.0
resolution: "lightningcss-darwin-x64@npm:1.32.0"
@@ -2769,6 +3037,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-darwin-x64@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-darwin-x64@npm:1.33.0"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
"lightningcss-freebsd-x64@npm:1.32.0":
version: 1.32.0
resolution: "lightningcss-freebsd-x64@npm:1.32.0"
@@ -2776,6 +3051,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-freebsd-x64@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-freebsd-x64@npm:1.33.0"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
"lightningcss-linux-arm-gnueabihf@npm:1.32.0":
version: 1.32.0
resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0"
@@ -2783,6 +3065,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-linux-arm-gnueabihf@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-arm-gnueabihf@npm:1.33.0"
+ conditions: os=linux & cpu=arm
+ languageName: node
+ linkType: hard
+
"lightningcss-linux-arm64-gnu@npm:1.32.0":
version: 1.32.0
resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0"
@@ -2790,6 +3079,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-linux-arm64-gnu@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-arm64-gnu@npm:1.33.0"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"lightningcss-linux-arm64-musl@npm:1.32.0":
version: 1.32.0
resolution: "lightningcss-linux-arm64-musl@npm:1.32.0"
@@ -2797,6 +3093,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-linux-arm64-musl@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-arm64-musl@npm:1.33.0"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
"lightningcss-linux-x64-gnu@npm:1.32.0":
version: 1.32.0
resolution: "lightningcss-linux-x64-gnu@npm:1.32.0"
@@ -2804,6 +3107,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-linux-x64-gnu@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-x64-gnu@npm:1.33.0"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"lightningcss-linux-x64-musl@npm:1.32.0":
version: 1.32.0
resolution: "lightningcss-linux-x64-musl@npm:1.32.0"
@@ -2811,6 +3121,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-linux-x64-musl@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-x64-musl@npm:1.33.0"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
"lightningcss-win32-arm64-msvc@npm:1.32.0":
version: 1.32.0
resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0"
@@ -2818,6 +3135,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-win32-arm64-msvc@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-win32-arm64-msvc@npm:1.33.0"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
"lightningcss-win32-x64-msvc@npm:1.32.0":
version: 1.32.0
resolution: "lightningcss-win32-x64-msvc@npm:1.32.0"
@@ -2825,6 +3149,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-win32-x64-msvc@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-win32-x64-msvc@npm:1.33.0"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
"lightningcss@npm:^1.32.0":
version: 1.32.0
resolution: "lightningcss@npm:1.32.0"
@@ -2868,6 +3199,49 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss@npm:^1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss@npm:1.33.0"
+ dependencies:
+ detect-libc: "npm:^2.0.3"
+ lightningcss-android-arm64: "npm:1.33.0"
+ lightningcss-darwin-arm64: "npm:1.33.0"
+ lightningcss-darwin-x64: "npm:1.33.0"
+ lightningcss-freebsd-x64: "npm:1.33.0"
+ lightningcss-linux-arm-gnueabihf: "npm:1.33.0"
+ lightningcss-linux-arm64-gnu: "npm:1.33.0"
+ lightningcss-linux-arm64-musl: "npm:1.33.0"
+ lightningcss-linux-x64-gnu: "npm:1.33.0"
+ lightningcss-linux-x64-musl: "npm:1.33.0"
+ lightningcss-win32-arm64-msvc: "npm:1.33.0"
+ lightningcss-win32-x64-msvc: "npm:1.33.0"
+ dependenciesMeta:
+ lightningcss-android-arm64:
+ optional: true
+ lightningcss-darwin-arm64:
+ optional: true
+ lightningcss-darwin-x64:
+ optional: true
+ lightningcss-freebsd-x64:
+ optional: true
+ lightningcss-linux-arm-gnueabihf:
+ optional: true
+ lightningcss-linux-arm64-gnu:
+ optional: true
+ lightningcss-linux-arm64-musl:
+ optional: true
+ lightningcss-linux-x64-gnu:
+ optional: true
+ lightningcss-linux-x64-musl:
+ optional: true
+ lightningcss-win32-arm64-msvc:
+ optional: true
+ lightningcss-win32-x64-msvc:
+ optional: true
+ checksum: 10c0/ce1f8279fbae636dbf37fa6e7385d5f98ed881d72af3362f24afbd4685e19c1fcdfecf17e5dd77f2ebee3d0c23ade276230d85842d07292229a2cffba8ff20a3
+ languageName: node
+ linkType: hard
+
"lilconfig@npm:^2.0.5":
version: 2.1.0
resolution: "lilconfig@npm:2.1.0"
@@ -3400,6 +3774,15 @@ __metadata:
languageName: node
linkType: hard
+"nanoid@npm:^3.3.17":
+ version: 3.3.18
+ resolution: "nanoid@npm:3.3.18"
+ bin:
+ nanoid: bin/nanoid.cjs
+ checksum: 10c0/b994b4e396730f8be2520923284e2040d61eaee55cc6d4935ef6d38d34bafdc46133eda4d3faea5073bda545aa6079d82b886caeac5c731cf9ac18bcc1301425
+ languageName: node
+ linkType: hard
+
"natural-compare@npm:^1.4.0":
version: 1.4.0
resolution: "natural-compare@npm:1.4.0"
@@ -3630,6 +4013,13 @@ __metadata:
languageName: node
linkType: hard
+"picomatch@npm:^4.0.3, picomatch@npm:^4.0.5":
+ version: 4.0.5
+ resolution: "picomatch@npm:4.0.5"
+ checksum: 10c0/947bc6b6e1ff1e6c5aaf95b107a0839d12802f4f7b867663f67d47accba939ca1cb582cf99dfc30438efa1c4648ac5990967e783e8929c36b03e8440704ef1bd
+ languageName: node
+ linkType: hard
+
"pify@npm:^4.0.1":
version: 4.0.1
resolution: "pify@npm:4.0.1"
@@ -3725,6 +4115,17 @@ __metadata:
languageName: node
linkType: hard
+"postcss@npm:^8.5.26":
+ version: 8.5.26
+ resolution: "postcss@npm:8.5.26"
+ dependencies:
+ nanoid: "npm:^3.3.17"
+ picocolors: "npm:^1.1.1"
+ source-map-js: "npm:^1.2.1"
+ checksum: 10c0/2bdafc00d96bd57b6649a52e458864a4bf58ee56cfdbe4aea1472b5cccc127e6c1ad653bd0bec50d211e650eb0b9270c80e1e72aff2e2fa40d9e7363234d6e43
+ languageName: node
+ linkType: hard
+
"prelude-ls@npm:^1.2.1":
version: 1.2.1
resolution: "prelude-ls@npm:1.2.1"
@@ -3984,6 +4385,64 @@ __metadata:
languageName: node
linkType: hard
+"rolldown@npm:~1.2.4":
+ version: 1.2.5
+ resolution: "rolldown@npm:1.2.5"
+ dependencies:
+ "@oxc-project/types": "npm:=0.146.0"
+ "@rolldown/binding-android-arm-eabi": "npm:1.2.5"
+ "@rolldown/binding-android-arm64": "npm:1.2.5"
+ "@rolldown/binding-darwin-arm64": "npm:1.2.5"
+ "@rolldown/binding-darwin-x64": "npm:1.2.5"
+ "@rolldown/binding-freebsd-x64": "npm:1.2.5"
+ "@rolldown/binding-linux-arm-gnueabihf": "npm:1.2.5"
+ "@rolldown/binding-linux-arm64-gnu": "npm:1.2.5"
+ "@rolldown/binding-linux-arm64-musl": "npm:1.2.5"
+ "@rolldown/binding-linux-ppc64-gnu": "npm:1.2.5"
+ "@rolldown/binding-linux-s390x-gnu": "npm:1.2.5"
+ "@rolldown/binding-linux-x64-gnu": "npm:1.2.5"
+ "@rolldown/binding-linux-x64-musl": "npm:1.2.5"
+ "@rolldown/binding-openharmony-arm64": "npm:1.2.5"
+ "@rolldown/binding-win32-arm64-msvc": "npm:1.2.5"
+ "@rolldown/binding-win32-x64-msvc": "npm:1.2.5"
+ "@rolldown/pluginutils": "npm:^1.0.0"
+ dependenciesMeta:
+ "@rolldown/binding-android-arm-eabi":
+ optional: true
+ "@rolldown/binding-android-arm64":
+ optional: true
+ "@rolldown/binding-darwin-arm64":
+ optional: true
+ "@rolldown/binding-darwin-x64":
+ optional: true
+ "@rolldown/binding-freebsd-x64":
+ optional: true
+ "@rolldown/binding-linux-arm-gnueabihf":
+ optional: true
+ "@rolldown/binding-linux-arm64-gnu":
+ optional: true
+ "@rolldown/binding-linux-arm64-musl":
+ optional: true
+ "@rolldown/binding-linux-ppc64-gnu":
+ optional: true
+ "@rolldown/binding-linux-s390x-gnu":
+ optional: true
+ "@rolldown/binding-linux-x64-gnu":
+ optional: true
+ "@rolldown/binding-linux-x64-musl":
+ optional: true
+ "@rolldown/binding-openharmony-arm64":
+ optional: true
+ "@rolldown/binding-win32-arm64-msvc":
+ optional: true
+ "@rolldown/binding-win32-x64-msvc":
+ optional: true
+ bin:
+ rolldown: ./bin/cli.mjs
+ checksum: 10c0/f6b4840300dcf4bb1b1f901fbc55d7642caefe63fd68e3a804f99eece8f4abae39dd3cd481d395e27f73fe55111563ea2511fef758f62f4c19af7ce2b42088e9
+ languageName: node
+ linkType: hard
+
"rollup-plugin-sbom@npm:^3.0.0":
version: 3.2.2
resolution: "rollup-plugin-sbom@npm:3.2.2"
@@ -4098,6 +4557,13 @@ __metadata:
languageName: node
linkType: hard
+"siginfo@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "siginfo@npm:2.0.0"
+ checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34
+ languageName: node
+ linkType: hard
+
"signal-exit@npm:^4.0.1":
version: 4.1.0
resolution: "signal-exit@npm:4.1.0"
@@ -4238,6 +4704,20 @@ __metadata:
languageName: node
linkType: hard
+"stackback@npm:0.0.2":
+ version: 0.0.2
+ resolution: "stackback@npm:0.0.2"
+ checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983
+ languageName: node
+ linkType: hard
+
+"std-env@npm:^4.0.0-rc.1":
+ version: 4.2.0
+ resolution: "std-env@npm:4.2.0"
+ checksum: 10c0/40ac525ce7b7c556abc332a7376f14356eeb1a7f17f6ff9a003eb9f52326ff1f3745d3e1b43452675b1ec6fcc319f1b1d6f3b0d386cf3f91058479ad883cff69
+ languageName: node
+ linkType: hard
+
"string-ts@npm:^2.3.1":
version: 2.3.1
resolution: "string-ts@npm:2.3.1"
@@ -4337,6 +4817,20 @@ __metadata:
languageName: node
linkType: hard
+"tinybench@npm:^2.9.0":
+ version: 2.9.0
+ resolution: "tinybench@npm:2.9.0"
+ checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c
+ languageName: node
+ linkType: hard
+
+"tinyexec@npm:^1.0.2":
+ version: 1.3.0
+ resolution: "tinyexec@npm:1.3.0"
+ checksum: 10c0/e9b89f97489d2aab2cef408da279e6b32547e738d1275032ccb8fd0028a006d93eb70fc51c6cffd9fc2f5aca6c2a273d8b6f73b52d46ee5116da6b94969ef958
+ languageName: node
+ linkType: hard
+
"tinyexec@npm:^1.1.2":
version: 1.2.2
resolution: "tinyexec@npm:1.2.2"
@@ -4354,6 +4848,13 @@ __metadata:
languageName: node
linkType: hard
+"tinyrainbow@npm:^3.1.0":
+ version: 3.1.1
+ resolution: "tinyrainbow@npm:3.1.1"
+ checksum: 10c0/f9d2743832c6191f753408f36224fe817620b8abcef572b2e570204c673a901d753ff84ca8e7b88f9c79e934295b3ffc6fcbc56a06f126e24e1ec6186dcad40d
+ languageName: node
+ linkType: hard
+
"to-regex-range@npm:^5.0.1":
version: 5.0.1
resolution: "to-regex-range@npm:5.0.1"
@@ -4632,6 +5133,63 @@ __metadata:
languageName: node
linkType: hard
+"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0":
+ version: 8.2.2
+ resolution: "vite@npm:8.2.2"
+ dependencies:
+ fsevents: "npm:~2.3.3"
+ lightningcss: "npm:^1.33.0"
+ picomatch: "npm:^4.0.5"
+ postcss: "npm:^8.5.26"
+ rolldown: "npm:~1.2.4"
+ tinyglobby: "npm:^0.2.17"
+ peerDependencies:
+ "@types/node": ^20.19.0 || >=22.12.0
+ "@vitejs/devtools": ^0.4.0 || ^0.5.0
+ esbuild: ^0.27.0 || ^0.28.0
+ jiti: ">=1.21.0"
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: ">=0.54.8"
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ dependenciesMeta:
+ fsevents:
+ optional: true
+ peerDependenciesMeta:
+ "@types/node":
+ optional: true
+ "@vitejs/devtools":
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+ bin:
+ vite: bin/vite.js
+ checksum: 10c0/94cbbbdc38ad500dcb86b6202ddd14aa41d05c80739766cada9bbe250b410d1a27be433c9c491ed39744019471ac1e27a59908616a88c42f9787bdb6bdca49d2
+ languageName: node
+ linkType: hard
+
"vite@npm:^8.0.14":
version: 8.0.16
resolution: "vite@npm:8.0.16"
@@ -4689,6 +5247,74 @@ __metadata:
languageName: node
linkType: hard
+"vitest@npm:^4.1.11":
+ version: 4.1.11
+ resolution: "vitest@npm:4.1.11"
+ dependencies:
+ "@vitest/expect": "npm:4.1.11"
+ "@vitest/mocker": "npm:4.1.11"
+ "@vitest/pretty-format": "npm:4.1.11"
+ "@vitest/runner": "npm:4.1.11"
+ "@vitest/snapshot": "npm:4.1.11"
+ "@vitest/spy": "npm:4.1.11"
+ "@vitest/utils": "npm:4.1.11"
+ es-module-lexer: "npm:^2.0.0"
+ expect-type: "npm:^1.3.0"
+ magic-string: "npm:^0.30.21"
+ obug: "npm:^2.1.1"
+ pathe: "npm:^2.0.3"
+ picomatch: "npm:^4.0.3"
+ std-env: "npm:^4.0.0-rc.1"
+ tinybench: "npm:^2.9.0"
+ tinyexec: "npm:^1.0.2"
+ tinyglobby: "npm:^0.2.15"
+ tinyrainbow: "npm:^3.1.0"
+ vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0"
+ why-is-node-running: "npm:^2.3.0"
+ peerDependencies:
+ "@edge-runtime/vm": "*"
+ "@opentelemetry/api": ^1.9.0
+ "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
+ "@vitest/browser-playwright": 4.1.11
+ "@vitest/browser-preview": 4.1.11
+ "@vitest/browser-webdriverio": 4.1.11
+ "@vitest/coverage-istanbul": 4.1.11
+ "@vitest/coverage-v8": 4.1.11
+ "@vitest/ui": 4.1.11
+ happy-dom: "*"
+ jsdom: "*"
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ "@edge-runtime/vm":
+ optional: true
+ "@opentelemetry/api":
+ optional: true
+ "@types/node":
+ optional: true
+ "@vitest/browser-playwright":
+ optional: true
+ "@vitest/browser-preview":
+ optional: true
+ "@vitest/browser-webdriverio":
+ optional: true
+ "@vitest/coverage-istanbul":
+ optional: true
+ "@vitest/coverage-v8":
+ optional: true
+ "@vitest/ui":
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+ vite:
+ optional: false
+ bin:
+ vitest: ./vitest.mjs
+ checksum: 10c0/3fa0948cf74adcccc8cbcdb4e6d30ada6933bdfb1816ff99200f3d3b689325b37dc483b22535b57b6d911f7a7b64eaa6a5f8da1606cfe7f2466f48000c21e296
+ languageName: node
+ linkType: hard
+
"void-elements@npm:3.1.0":
version: 3.1.0
resolution: "void-elements@npm:3.1.0"
@@ -4718,6 +5344,18 @@ __metadata:
languageName: node
linkType: hard
+"why-is-node-running@npm:^2.3.0":
+ version: 2.3.0
+ resolution: "why-is-node-running@npm:2.3.0"
+ dependencies:
+ siginfo: "npm:^2.0.0"
+ stackback: "npm:0.0.2"
+ bin:
+ why-is-node-running: cli.js
+ checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054
+ languageName: node
+ linkType: hard
+
"word-wrap@npm:^1.2.5":
version: 1.2.5
resolution: "word-wrap@npm:1.2.5"