Skip to content

feat(library): a fill layout, open attributes, a pluggable loader and the tier outside JImage - #766

Draft
romain-pm wants to merge 10 commits into
feat/image-apifrom
feat/image-improvements
Draft

feat(library): a fill layout, open attributes, a pluggable loader and the tier outside JImage#766
romain-pm wants to merge 10 commits into
feat/image-apifrom
feat/image-improvements

Conversation

@romain-pm

@romain-pm romain-pm commented Aug 23, 2026

Copy link
Copy Markdown

Summary

Second pass on #734, from adopting the image API on Jahia/jahia.com (25 image sites) and a comparison against astro 7.1.3 and next/image 16.3.2. Implements #762, #761, #763, #765, decides #764, closes the five findings of #767, and restructures the layout set per #774.

<JImage node={card} alt="" sizes="auto" className={classes.card} />

That is the slot a real site actually has, and it is what the API could not express. A slot is described once — a slotWidth when the markup knows its width in CSS pixels, a sizes when only CSS knows it — and the candidate files follow that one description.

Why

On jahia.com every image slot is fluid, so layout went unused everywhere: the site has exactly one fixed-pixel slot (a 15rem avatar) and no other image has a width in CSS pixels at any breakpoint. All 25 sites fell back to the widths + sizes escape hatches. Two of them could not use <JImage> at all, because it dropped the height attribute their card icons take their box from, and re-applied getImageProps by hand instead.

A developer must never be pushed off the component because it swallowed an attribute, and the layout vocabulary has to cover the slot nobody can measure.

Changes

  • One description of the slot (Image and link APIs: what to fix before release #774) — constrained takes a slotWidth xor a sizes, as a type error on both JImageProps and ImageOptions, with a runtime throw for the untyped caller and the {...props} spread that defeats a union. Both was two claims about one box: the sizes was emitted and the ladder still came from the slotWidth, and three measured luxe call sites were served 0.52–0.56x of what their own sizes asked for. fixed rejects a sizes — the layout's whole meaning is that the slot is one number. The short-lived layout="fluid" is deleted: it had zero call sites in all three repos, and the slot it named is the ordinary constrained one spelled with a sizes.
  • The candidate ladder is derived from sizes (Image and link APIs: what to fix before release #774) — Next's direction (get-img-props.js:51), not its implementation. Its regex /(^|\s)(1?\d?\d)vw/g misses calc(100vw - 2rem) and 33.3vw and derives no ceiling; the new parser reads each entry's source size (skipping the media condition, so the 1024px in (min-width: 1024px) 33vw is never mistaken for a slot width), handles vw, px, decimals, calc(), min(), max() and clamp(), and uses the ladder's own extremes as the viewport range. It keeps every breakpoint from the narrowest slot the string can describe up to and including the first one that reaches twice the widest. Anything it cannot read — auto, a %, an em, a malformed string — falls back to the whole ladder, never to a narrow one: that failure direction is the bug being fixed.
  • The off-by-a-band filter (Image and link APIs: what to fix before release #774) — candidateWidths filtered breakpoints.filter(c => c < width), skipping every band between W and 2W. Astro keeps them (layout.js:69-76) and so do we now: slotWidth={960} gives [320, 640, 960, 1280, 1920] where it gave [320, 640, 960, 1920], so a 1.33x screen at the full slot gets the 1280 file instead of the 1920 one.
  • layout="fill" (JImage: a fill layout and first-class sizes=auto for fluid slots #761) — the image positioned over its closest positioned ancestor, which owns the box. No slot width, the whole candidate ladder, no intrinsic dimensions (they would fight the parent's box), and sizes required. It is a real layout, just a rarer one than the guide first claimed: fluid is what most slots want.
  • sizes="auto" (JImage: a fill layout and first-class sizes=auto for fluid slots #761, Image API: five things to fix before release #767) — was silently broken: it is only valid with loading="lazy", and <JImage> emitted lazy only once j:width/j:height were known. It now forces lazy. Combined with preload or loading="eager" it no longer throws — those two legitimately arrive from different layers, and a shared wrapper's default must not 500 a production page — so the eager load wins, auto becomes 100vw, and a development instance warns once naming the image. 100vw is the only replacement left: a slot spelled with sizes carries no width to derive a better one from. getImageProps reports loading: "lazy" alongside auto, so an island that spreads the props keeps the pairing.
  • Attribute pass-through (JImage: never swallow an attribute the caller needs #762, Image API: five things to fix before release #767) — the omit is derived from the component's own props (Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "srcSet" | keyof JImageProps>), so a prop added later cannot silently eat an attribute. width/height are re-declared as the HTML attributes and win over the intrinsic pair. They are required as a pair, in the type and at runtime: two of them state one box, and the union turns the widthslotWidth rename into a compile error at the call site instead of a throw on first render.
  • getImageProps gains fallback and requires its context (Image API: five things to fix before release #767) — both of its real users reached past it, hand-building a bare { src, alt } with no srcSet, no dimensions and no cache dependency, and an omitted context silently dropped both the cache dependency and setImageDefaults. It now mirrors the component: a missing node with a fallback returns the fallback's props, without one it returns null.
  • ImagePropsImgProps, ImageAttributesExtraImageAttributes (Image API: five things to fix before release #767) — ImgProps is the data that comes out, as opposed to JImageProps, the component's own props. That is what next/image means by ImageProps and what the link API in feat(library): a link API for JavaScript modules #751 names AnchorProps. Free now, breaking after release.
  • widths without a slotWidth (Image API: five things to fix before release #767) — used to emit sizes="(min-width: undefinedpx) undefinedpx, 100vw", which browsers discard before fetching the largest candidate on every screen. The slotWidth guard candidateWidths had is now shared with derivedSizes, which is the function the explicit-widths path actually reaches.
  • loader, quality, unoptimized (A pluggable image loader, with quality and unoptimized #763) — per call, or once per module with setImageDefaults. inspectImageChannel reports the new loader channel, and the dev-mode "this instance ignores ?w=" warning correctly stays quiet when a module owns the URLs.
  • The tier outside the component (Complete the image tier outside the component #765) — buildImageUrl registers the cache dependency it was missing; buildBackgroundImageUrl returns a ready url("…"); buildThumbnailUrl exposes the smallest pre-generated thumbnail; absolute lands on buildNodeUrl and is surfaced on every image function. readImageMeta was already exported on this branch, so that bullet is closed as done.
  • Development warnings (Image API: five things to fix before release #767) — one shared, dev-only, once-per-key channel: the ?w= candidates this instance ignores, the sizes="auto" an eager load overrode, and a raster node carrying no j:width — which silently costs it both the CLS reservation and loading="lazy", and which the guide never named.
  • Docs — the layout section is rewritten around the two questions that decide the spelling, with one row per real slot shape: fixed pixel, viewport-spanning, max-width container, grid cell, aspect-ratio box, positioned over parent, height-constrained. The aspect-ratio box and the height-constrained slot each get their own subsection, the second one saying plainly that no framework has a precise answer for it and what to write instead. New section on how a sizes string produces the ladder. fill keeps its own section with the parent's CSS in the snippet, and "what actually resizes the image" sits just after the table, with a section on j:width.
  • Tests — 125, up from 27, and tsconfig.spec.json type-checks the specs the build excludes. The three measured luxe cases are regression tests: the slotWidth + sizes spelling is asserted to be a type error, and the sizes-only spelling to produce a ladder covering 768px and 893px at DPR 2. Every parser branch has a case, including the give-up path.

Validation

Decided, and what is left

  • Restructure the layout set, do not extend it (Image and link APIs: what to fix before release #774) — fluid was a fourth name for a distinction the vocabulary did not need. The real axis is not "which layout" but "which of the two descriptions of the slot", and constrained already carried both. Astro derives the same constrained string we do (layout.js:89-90) and has the same bug: widths ||= getWidths(...) and sizes ||= getSizesAttribute(...) are independent statements ten lines apart (internal.js:117, :123), verified by building 16 call sites and reading the emitted HTML. Next has no such gap, and that is the direction taken here.
  • The parser fails wide, on purpose — a sizes it cannot read returns the whole ladder. A ladder that is too wide costs a few bytes of markup; one that is too narrow ships under-served images and says nothing, which is the defect this PR exists to remove. The bounds of a math function err outward for the same reason.
  • preload, not prioritynext/image 16 renamed it and deprecated the old spelling. Nothing is released here, so there is no alias to carry and no deprecation debt to take on. It sets loading="eager" + fetchpriority="high"; it does not emit a <link rel="preload">, which needs head injection and is a separate change.
  • fill is a layout value, not a fill boolean — one axis cannot contradict itself, so there is no "both layout and fill were passed" validation to write.
  • fill and placeholder emit style — the component otherwise promises no styling opinion, and keeps it: these two are styling, they are opt-in, and a caller's own style wins over both.
  • slotWidth — renamed from width across all three tiers, so one name means one thing. width is now the HTML attribute everywhere, and the required pair makes the old spelling fail loudly.
  • Module defaults are keyed by bundle — every JavaScript module in an instance shares one GraalJS context (GraalVMEngine.ContextPoolFactory evaluates every bundle's init script into the same Context), so a plain module-level variable in the library would be engine-wide: one module's loader would rewrite another module's images. setImageDefaults reads the bundleKey global the engine sets while it evaluates a bundle, and useServerContext().bundleKey selects it back at render time. It throws when called outside a bundle rather than leaking to everybody.
  • absolute prefers the target site's server name over the request's, because a cross-site URL (og:image on a shared asset, JSON-LD pointing at another site) must name the site it points at — which is exactly what core's JCRNodeWrapper.getAbsoluteUrl(request) cannot do. Falls back to the request when the site declares none, and absolute: "https://example.com" names it outright.
  • placeholder=blur from Jahia thumbnails #764 placeholder="blur": the blocking question is answered, and the answer is "yes, but not worth it yet". Reading thumbnail bytes at render time is possible in the GraalJS server runtime — the engine builds its context with allowHostClassLookup(s -> true) and HostAccess.ALL, so Java.type("java.util.Base64") and a synchronous getBinary().getStream() are both reachable, and no async I/O is involved. What stops it is cost, not capability: a datastore read plus a base64 encode per image per render, and 7–20 KB of inline HTML each. So this PR ships the URL-reference formplaceholder="blur" paints the smallest pre-generated thumbnail as a background-image, and blurDataURL takes a caller-supplied data URI or URL. Two honest limits, both documented: the placeholder is not cleared once the image loads (server-only component, no client code to clear it), so it stays behind a transparent PNG; and the SVG-filter blur next/image uses cannot work from a URL, because a data: URI SVG may not load external subresources — the blur here is the browser upscaling a 150px file.
  • Still to do: delete src/jcr-provider-augmentation.d.ts once target/types is regenerated (inherited from feat(library): an image API for JavaScript modules #746). feat(library): a link API for JavaScript modules #751 will need a rebase — this PR changes buildNodeUrl, renames ImageProps, and buildNodeUrl gaps a link component cannot work around #756's absolute bullet is now implemented here rather than there. Five call sites on refactor(images): adopt the platform image API and delete commons/image luxe-jahia-demo#458 are now type errors — the same five that are under-serving today — and drop their slotWidth to keep their sizes. jahia.com's <Image> wrapper passes layout, slotWidth and sizes as three independent optionals and needs its own union. A slot stated only by the width/height HTML attributes must now also describe itself, so <JImage node={icon} alt="" width={48} height={48} /> becomes layout="fixed" slotWidth={48} width={48} height={48}; treating the markup box as a slot description is possible and deliberately not done here. And the height-constrained slot still has no precise answer in any framework — the guide names the workaround, a real answer is follow-up work.

Part of #734; each commit references the sub-issue it implements.

`og:url`, `og:image`, a canonical link and JSON-LD all need a scheme and a
host, and there was no way to ask for one: Jahia/jahia.com hardcodes its own
origin six times and reaches past the library to the Java
`JCRNodeWrapper.getAbsoluteUrl(request)` for the rest.

`absolute: true` resolves the origin from the *target* site's server name,
falling back to the request when the site declares none — a link to a page of
another site must name that site's server, which is exactly what core's
request-based helper cannot do. `absolute: "https://example.com"` names it
outright, for a reverse proxy or a canonical domain.

It lives in its own module so both the URL tier and the image tier can reach
it without importing React.

Part of #765, and the shared half of
#756 — the link API will want the same option.
Resize routing was decided inside `buildImageUrl` and a module could not get
in. A project on a CDN, a custom DAM or a Media Optimization setup that speaks
another URL dialect had no way to say so.

`loader({ src, width, quality })` replaces the routing, `quality` is passed to
it, and `unoptimized` opts one image out of candidate generation entirely —
`next/image`'s three escape hatches, under their own names.

`setImageDefaults` sets them once per module. It is keyed by the bundle the
call came from, because every JavaScript module in an instance shares one
GraalJS context: a plain module-level variable here would let one module's
loader rewrite another module's images.

Part of #763.
Three holes each found by a real call site on Jahia/jahia.com.

`buildImageUrl` now registers the render cache dependency `getImageProps`
already registered. It is the only option for a CSS background image, so a
background silently lost the flush an `<img>` got.

`buildBackgroundImageUrl` returns a ready `url("…")` value, quoted and with
its commas percent-encoded — a DAM URL containing a comma breaks the CSS layer
list the way it breaks `srcSet` (Jahia/jahia#23). A `data:` URI is left alone:
the comma in `data:image/png;base64,…` separates the header from the payload,
and encoding it would destroy the image rather than protect it.

`buildThumbnailUrl` exposes the smallest pre-generated thumbnail, which is the
one variant a plain instance produces offline.

Also carries the loader, quality, unoptimized and absolute options through the
channel routing, and adds `loader` to the reported channel so
`inspectImageChannel` stays honest when a module owns the URLs.

Part of #765 and #763.
`layout` plus a slot width assumes the call site knows its slot in pixels.
Adopting the API on Jahia/jahia.com moved all 25 image sites and every one of
them abandoned `layout`: the site has exactly one fixed-pixel slot, a 15rem
avatar, and no other image has a width in CSS pixels at any breakpoint.

`layout="fill"` is the slot whose width no view can know — a percentage, a
grid cell, an aspect-ratio box. It needs no slot width, draws the whole
candidate ladder, omits the intrinsic dimensions that would fight the parent's
box, and requires `sizes`, because nothing else can describe it.

`sizes="auto"` is now a resolved conflict rather than a silent one. It is only
valid with `loading="lazy"`, which the component emitted conditionally, so the
two quietly cancelled out. `getImageProps` now reports `loading: "lazy"`
alongside an `auto` sizes, so an island that spreads the props gets the
pairing too.

The slot width is renamed `slotWidth`, freeing `width` for the HTML attribute
it always looked like.

Part of #761 and #762.
Jahia/jahia.com's card icons take their box from the `width`/`height` HTML
attributes and have no CSS rule at all, so they could not use the component:
it dropped `height` for good, and hand-listed what it omitted from
`ImgHTMLAttributes`.

The omit is now derived from the component's own props, so a prop added later
cannot silently eat an attribute — only `src` and `srcSet`, which the
component computes, are additionally withheld. `width` and `height` are
re-declared as the HTML attributes and win over the intrinsic pair; writing
one stops the other being emitted, because half of each would state a wrong
aspect ratio.

`attributes` is the open map, spread last, taking a record or a function of
the resolved image — the form an analytics attribute derived from the final
`src` needs, and the way `data-*` reaches the element at all, since React's
typings do not model it.

`priority` becomes `preload`, following `next/image` 16, which renamed it and
deprecated the old spelling. Nothing is released yet, so there is no alias to
carry.

Also lands `layout="fill"`'s positioning style and `placeholder="blur"`, both
opt-in and both styling the component would otherwise refuse to emit.

Part of #762, #761 and
#764.
94 tests over the fill layout, `sizes="auto"`, attribute pass-through, the
loader and its module-wide defaults, quality routing, absolute URLs,
background URLs and the cache dependency.

The build excludes `*.spec.ts` so they never reach `dist`, which also meant
they were never type-checked. `tsconfig.spec.json` gives them their own pass.
The guide presented `widths`/`sizes` as the exception and `layout` + a slot
width as the norm. On a real site it is the other way round, so the layout
table now starts from the question that decides it — whether anything in your
markup knows how wide the image is — and names the layout each kind of slot
wants.

Adds the sections the new surface needs: attribute pass-through, the
`attributes` map, `placeholder`, loaders and module defaults, background
images, absolute URLs. Renames the slot width to `slotWidth` in the tutorial
and the hydrogen sample, and `priority` to `preload` throughout.

Part of #761.
@github-actions

Copy link
Copy Markdown

📝 Documentation Guidelines

Thank you for contributing to our documentation! To ensure your contributions meet our standards, please review these resources:

This comment is posted automatically when changes are detected in the docs/ folder.

@pkg-pr-new

pkg-pr-new Bot commented Aug 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

yarn add https://pkg.pr.new/@jahia/create-module@766.tgz
yarn add https://pkg.pr.new/@jahia/javascript-modules-library@766.tgz
yarn add https://pkg.pr.new/@jahia/vite-plugin@766.tgz

commit: 73976ab

Five things the adversarial DX review in #767 found, after the API was adopted
on three real codebases.

`layout="fluid"` names the slot the markup cannot measure but the image still
sits in: a percentage, a grid cell, an `aspect-ratio` box, a slot constrained by
its height. That is the shape `fill` was standing in for, and `fill` positions
the image over a parent — which 0 of 16 luxe sites and 9 of 13 fluid jahia.com
sites could use. `fill` now means only what it says; both layouts draw the same
candidates, so only the positioning and the intrinsic pair differ.

`widths` without a `slotWidth` emitted `sizes="(min-width: undefinedpx)
undefinedpx, 100vw"`, which browsers discard before fetching the largest
candidate on every screen — the exact failure the API exists to prevent. The
guard `candidateWidths` already had is now shared with `derivedSizes`, which is
what the explicit-`widths` path reaches instead.

`width` and `height` are required together, so a call site written against the
previous API — where `width` was the slot — fails where it is written instead of
type-checking and throwing on first render.

`getImageProps` takes a `fallback` and requires its context. Both of its real
users reached past it and hand-built a bare `{ src, alt }` with no srcSet, no
dimensions and no cache dependency, and an omitted context silently dropped both
the cache dependency and `setImageDefaults`.

`ImageProps` becomes `ImgProps` — the data that comes out, as opposed to
`JImageProps`, the component's own props — which is what `next/image` means by
`ImageProps` and what the link API next door names `AnchorProps`.
`ImageAttributes` becomes `ExtraImageAttributes`.

Two runtime edges stop being fatal or silent. `sizes="auto"` with `preload` no
longer throws: those props legitimately arrive from different layers, a shared
wrapper cannot express the exclusion, and a 500 on a production page is worse
than a wasteful `sizes` — so the eager load wins, the layout's own `sizes`
replaces `auto`, and a development instance says so. And a raster node with no
`j:width`, which silently loses both its CLS reservation and `loading="lazy"`,
now warns once.

Part of #767.
The guide led with `layout="fill"` and routed every fluid slot to it, but `fill`
positions the image over its parent: 0 of 16 luxe sites and 9 of 13 fluid
jahia.com sites could use it, and each codebase invented its own stand-in. The
short version now shows `fluid`, `alt` and `fallback` — the three props a first
reader needs, two of which were documented late or not at all — and `fill` gets
its own section, with the parent's CSS in the snippet and one consistent
statement of which element must be positioned.

The layout table names the two slot shapes that had no honest representation,
an `aspect-ratio` box and a height-constrained slot, and says why declaring a
`slotWidth` the CSS contradicts is worse than declaring none.

"What actually resizes the image" moves up to just after that table, because a
reader who does not yet know that a plain instance ignores `?w=` concludes their
srcSet is broken. It gains a section on `j:width`, which nothing named before.

Part of #767.
`fluid` is gone: it had zero call sites, and the slot it named is now the
ordinary `constrained` one spelled with a `sizes`.

`constrained` takes a `slotWidth` xor a `sizes`. Both was two descriptions
of one slot, and only the first reached the candidate ladder: three measured
luxe sites were served 0.52-0.56x of what their own `sizes` asked for. The
ladder is now derived from the `sizes` when there is one, by a parser that
reads vw and px terms, decimals, calc(), min(), max() and clamp(), skips the
lengths inside media conditions, and falls back to the whole ladder for
anything it cannot read. `fixed` rejects a `sizes`, its whole meaning being
that the slot is one number.

The constrained ladder keeps every breakpoint up to twice the slot width,
where it used to stop below the slot width and skip the whole band between
W and 2W.

Refs #774
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant