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
Draft
feat(library): a fill layout, open attributes, a pluggable loader and the tier outside JImage#766romain-pm wants to merge 10 commits into
romain-pm wants to merge 10 commits into
Conversation
`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.
📝 Documentation GuidelinesThank 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 |
This was referenced Aug 23, 2026
commit: |
This was referenced Aug 23, 2026
Draft
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.
This was referenced Aug 23, 2026
Draft
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Second pass on #734, from adopting the image API on Jahia/jahia.com (25 image sites) and a comparison against
astro7.1.3 andnext/image16.3.2. Implements #762, #761, #763, #765, decides #764, closes the five findings of #767, and restructures the layout set per #774.That is the slot a real site actually has, and it is what the API could not express. A slot is described once — a
slotWidthwhen the markup knows its width in CSS pixels, asizeswhen only CSS knows it — and the candidate files follow that one description.Why
On jahia.com every image slot is fluid, so
layoutwent 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 thewidths+sizesescape hatches. Two of them could not use<JImage>at all, because it dropped theheightattribute their card icons take their box from, and re-appliedgetImagePropsby 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
constrainedtakes aslotWidthxor asizes, as a type error on bothJImagePropsandImageOptions, with a runtime throw for the untyped caller and the{...props}spread that defeats a union. Both was two claims about one box: thesizeswas emitted and the ladder still came from theslotWidth, and three measured luxe call sites were served 0.52–0.56x of what their ownsizesasked for.fixedrejects asizes— the layout's whole meaning is that the slot is one number. The short-livedlayout="fluid"is deleted: it had zero call sites in all three repos, and the slot it named is the ordinaryconstrainedone spelled with asizes.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/gmissescalc(100vw - 2rem)and33.3vwand derives no ceiling; the new parser reads each entry's source size (skipping the media condition, so the1024pxin(min-width: 1024px) 33vwis never mistaken for a slot width), handlesvw,px, decimals,calc(),min(),max()andclamp(), 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%, anem, a malformed string — falls back to the whole ladder, never to a narrow one: that failure direction is the bug being fixed.candidateWidthsfilteredbreakpoints.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), andsizesrequired. It is a real layout, just a rarer one than the guide first claimed:fluidis 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 withloading="lazy", and<JImage>emitted lazy only oncej:width/j:heightwere known. It now forces lazy. Combined withpreloadorloading="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,autobecomes100vw, and a development instance warns once naming the image.100vwis the only replacement left: a slot spelled withsizescarries no width to derive a better one from.getImagePropsreportsloading: "lazy"alongsideauto, so an island that spreads the props keeps the pairing.Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "srcSet" | keyof JImageProps>), so a prop added later cannot silently eat an attribute.width/heightare 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 thewidth→slotWidthrename into a compile error at the call site instead of a throw on first render.getImagePropsgainsfallbackand requires itscontext(Image API: five things to fix before release #767) — both of its real users reached past it, hand-building a bare{ src, alt }with nosrcSet, no dimensions and no cache dependency, and an omitted context silently dropped both the cache dependency andsetImageDefaults. It now mirrors the component: a missing node with afallbackreturns the fallback's props, without one it returnsnull.ImageProps→ImgProps,ImageAttributes→ExtraImageAttributes(Image API: five things to fix before release #767) —ImgPropsis the data that comes out, as opposed toJImageProps, the component's own props. That is whatnext/imagemeans byImagePropsand what the link API in feat(library): a link API for JavaScript modules #751 namesAnchorProps. Free now, breaking after release.widthswithout aslotWidth(Image API: five things to fix before release #767) — used to emitsizes="(min-width: undefinedpx) undefinedpx, 100vw", which browsers discard before fetching the largest candidate on every screen. TheslotWidthguardcandidateWidthshad is now shared withderivedSizes, which is the function the explicit-widthspath actually reaches.loader,quality,unoptimized(A pluggable image loader, with quality and unoptimized #763) — per call, or once per module withsetImageDefaults.inspectImageChannelreports the newloaderchannel, and the dev-mode "this instance ignores?w=" warning correctly stays quiet when a module owns the URLs.buildImageUrlregisters the cache dependency it was missing;buildBackgroundImageUrlreturns a readyurl("…");buildThumbnailUrlexposes the smallest pre-generated thumbnail;absolutelands onbuildNodeUrland is surfaced on every image function.readImageMetawas already exported on this branch, so that bullet is closed as done.?w=candidates this instance ignores, thesizes="auto"an eager load overrode, and a raster node carrying noj:width— which silently costs it both the CLS reservation andloading="lazy", and which the guide never named.aspect-ratiobox, positioned over parent, height-constrained. Theaspect-ratiobox 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 asizesstring produces the ladder.fillkeeps 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 onj:width.tsconfig.spec.jsontype-checks the specs the build excludes. The three measured luxe cases are regression tests: theslotWidth+sizesspelling is asserted to be a type error, and thesizes-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
tsc --noEmiton the library: clean.tsc -p tsconfig.spec.json(the specs,@ts-expect-errorassertions included): clean.tsc --noEmitonsamples/hydrogen: clean.vitest run— 125 passing, 3 files.eslintclean,prettier --checkclean on everything this PR touches.yarn workspace @jahia/javascript-modules-library build— green, publint "All good!".Decided, and what is left
fluidwas 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", andconstrainedalready carried both. Astro derives the sameconstrainedstring we do (layout.js:89-90) and has the same bug:widths ||= getWidths(...)andsizes ||= 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.sizesit 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, notpriority—next/image16 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 setsloading="eager"+fetchpriority="high"; it does not emit a<link rel="preload">, which needs head injection and is a separate change.fillis alayoutvalue, not afillboolean — one axis cannot contradict itself, so there is no "bothlayoutandfillwere passed" validation to write.fillandplaceholderemit style — the component otherwise promises no styling opinion, and keeps it: these two are styling, they are opt-in, and a caller's ownstylewins over both.slotWidth— renamed fromwidthacross all three tiers, so one name means one thing.widthis now the HTML attribute everywhere, and the required pair makes the old spelling fail loudly.GraalVMEngine.ContextPoolFactoryevaluates every bundle's init script into the sameContext), so a plain module-level variable in the library would be engine-wide: one module's loader would rewrite another module's images.setImageDefaultsreads thebundleKeyglobal the engine sets while it evaluates a bundle, anduseServerContext().bundleKeyselects it back at render time. It throws when called outside a bundle rather than leaking to everybody.absoluteprefers the target site's server name over the request's, because a cross-site URL (og:imageon a shared asset, JSON-LD pointing at another site) must name the site it points at — which is exactly what core'sJCRNodeWrapper.getAbsoluteUrl(request)cannot do. Falls back to the request when the site declares none, andabsolute: "https://example.com"names it outright.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 withallowHostClassLookup(s -> true)andHostAccess.ALL, soJava.type("java.util.Base64")and a synchronousgetBinary().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 form —placeholder="blur"paints the smallest pre-generated thumbnail as abackground-image, andblurDataURLtakes 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 blurnext/imageuses cannot work from a URL, because adata:URI SVG may not load external subresources — the blur here is the browser upscaling a 150px file.src/jcr-provider-augmentation.d.tsoncetarget/typesis 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 changesbuildNodeUrl, renamesImageProps, and buildNodeUrl gaps a link component cannot work around #756'sabsolutebullet 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 theirslotWidthto keep theirsizes. jahia.com's<Image>wrapper passeslayout,slotWidthandsizesas three independent optionals and needs its own union. A slot stated only by thewidth/heightHTML attributes must now also describe itself, so<JImage node={icon} alt="" width={48} height={48} />becomeslayout="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.