From 5e7867ecfc3a1510f918686396721c911e965f29 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 14:44:43 -0400 Subject: [PATCH 1/4] perf(homepage): generate responsive images and reserve media space Generate AVIF and WebP candidates at build time, preserve intrinsic dimensions across homepage media, and defer below-fold images and video data. Cover candidate delivery, responsive selection, gallery behavior, and reserved layout without adding effects. --- README.md | 4 +- apps/docs/__tests__/ImageAsset_.test.res | 64 ++++++ apps/docs/__tests__/ImageGallery_.test.res | 32 ++- .../docs/__tests__/LandingPageVideo_.test.res | 28 +++ apps/docs/__tests__/ResponsiveImage_.test.res | 122 ++++++++++ apps/docs/app/routes/LandingPageVideo.res | 6 + apps/docs/app/routes/LandingPageVideo.resi | 3 + .../e2e-playwright/homepage-media.spec.mjs | 213 ++++++++++++++++++ apps/docs/package.json | 2 + apps/docs/src/bindings/NativeMedia.res | 27 +++ apps/docs/src/common/ImageAsset.res | 31 +++ apps/docs/src/common/ImageAsset.resi | 10 + apps/docs/src/components/Footer.res | 8 +- apps/docs/src/components/ImageGallery.res | 27 ++- .../LandingPageCuratedResources.res | 26 ++- .../LandingPageCuratedResources.resi | 1 + .../LandingPageMainSellingPoints.res | 28 +-- .../LandingPageMainSellingPoints.resi | 1 + .../LandingPageOtherSellingPoints.res | 19 +- .../LandingPageOtherSellingPoints.resi | 1 + .../src/components/LandingPagePlayground.res | 30 ++- .../src/components/LandingPagePlayground.resi | 2 +- .../src/components/LandingPageTrustedBy.res | 19 +- .../src/components/LandingPageTrustedBy.resi | 1 + apps/docs/src/components/NavbarPrimary.res | 12 +- apps/docs/src/components/ResponsiveImage.res | 30 +++ apps/docs/src/components/ResponsiveImage.resi | 8 + apps/docs/src/data/LandingPageImages.res | 50 ++++ apps/docs/src/data/LandingPageImages.resi | 5 + apps/docs/src/data/OurUsers.res | 87 ++++++- apps/docs/vite.config.mjs | 2 + apps/docs/vitest.config.mjs | 4 +- yarn.lock | 134 ++++++++++- 33 files changed, 974 insertions(+), 63 deletions(-) create mode 100644 apps/docs/__tests__/ImageAsset_.test.res create mode 100644 apps/docs/__tests__/LandingPageVideo_.test.res create mode 100644 apps/docs/__tests__/ResponsiveImage_.test.res create mode 100644 apps/docs/app/routes/LandingPageVideo.res create mode 100644 apps/docs/app/routes/LandingPageVideo.resi create mode 100644 apps/docs/e2e-playwright/homepage-media.spec.mjs create mode 100644 apps/docs/src/bindings/NativeMedia.res create mode 100644 apps/docs/src/common/ImageAsset.res create mode 100644 apps/docs/src/common/ImageAsset.resi create mode 100644 apps/docs/src/components/ResponsiveImage.res create mode 100644 apps/docs/src/components/ResponsiveImage.resi create mode 100644 apps/docs/src/data/LandingPageImages.res create mode 100644 apps/docs/src/data/LandingPageImages.resi diff --git a/README.md b/README.md index cf9bc7da5..e3bae1407 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,8 @@ If you are writing a blog post, refer to the [blog post guide](https://rescript- If your company uses ReScript and should appear in the "Trusted by our users" section on the front page: - Add a black and white `.svg` logo using `#979AAD` as the fill color. -- Put the file in [`public/lp`](public/lp). -- Update [`src/common/OurUsers.res`](src/common/OurUsers.res). +- Put the file in [`apps/docs/public/lp`](apps/docs/public/lp). +- Update [`apps/docs/src/data/OurUsers.res`](apps/docs/src/data/OurUsers.res), including the logo's intrinsic `width` and `height`. For SVGs without explicit dimensions, use their `viewBox` dimensions; scale fractional dimensions together to integers without changing the aspect ratio. - Commit, push, and open a PR. ## Contributing diff --git a/apps/docs/__tests__/ImageAsset_.test.res b/apps/docs/__tests__/ImageAsset_.test.res new file mode 100644 index 000000000..af4bf12b5 --- /dev/null +++ b/apps/docs/__tests__/ImageAsset_.test.res @@ -0,0 +1,64 @@ +open Vitest + +let metadata: ImageAsset.metadata = { + availableWidths: [360, 640, 1000], + imageUrlFor: (width, format) => { + let extension = switch format { + | #avif => "avif" + | #webp => "webp" + } + Some(`/images/photo-${width->Int.toString}.${extension}`) + }, +} + +let original: ImageAsset.t = { + src: "/images/photo.avif", + width: 1000, + height: 667, + source: Original, +} + +test("responsive source sets include every available width for the requested format", async () => { + expect(ImageAsset.sourceSet(metadata, #avif))->toEqual( + Some("/images/photo-360.avif 360w, /images/photo-640.avif 640w, /images/photo-1000.avif 1000w"), + ) + expect(ImageAsset.sourceSet(metadata, #webp))->toEqual( + Some("/images/photo-360.webp 360w, /images/photo-640.webp 640w, /images/photo-1000.webp 1000w"), + ) +}) + +test("source sets omit missing and blank image URLs", async () => { + let partial: ImageAsset.metadata = { + availableWidths: [360, 640, 1000], + imageUrlFor: (width, _format) => + switch width { + | 360 => Some("/images/photo-360.avif") + | 640 => None + | _ => Some(" ") + }, + } + expect(ImageAsset.sourceSet(partial, #avif))->toEqual(Some("/images/photo-360.avif 360w")) +}) + +test("empty and unavailable candidates omit the source set entirely", async () => { + expect(ImageAsset.sourceSet({...metadata, availableWidths: []}, #avif))->toEqual(None) + expect( + ImageAsset.sourceSet({...metadata, imageUrlFor: (_width, _format) => None}, #webp), + )->toEqual(None) +}) + +test("the fallback preserves originals and selects a generated WebP when available", async () => { + expect(ImageAsset.fallback(original))->toBe("/images/photo.avif") + expect(ImageAsset.fallback({...original, source: Responsive(metadata)}))->toBe( + "/images/photo-1000.webp", + ) +}) + +test("missing or blank generated fallback URLs retain the original image", async () => { + let missing = {...metadata, imageUrlFor: (_width, _format) => None} + let blank = {...metadata, imageUrlFor: (_width, _format) => Some("")} + expect(ImageAsset.fallback({...original, source: Responsive(missing)}))->toBe( + "/images/photo.avif", + ) + expect(ImageAsset.fallback({...original, source: Responsive(blank)}))->toBe("/images/photo.avif") +}) diff --git a/apps/docs/__tests__/ImageGallery_.test.res b/apps/docs/__tests__/ImageGallery_.test.res index 3366b6da8..4237f2489 100644 --- a/apps/docs/__tests__/ImageGallery_.test.res +++ b/apps/docs/__tests__/ImageGallery_.test.res @@ -1,11 +1,21 @@ open Vitest -let images = ["/lp/community-3.avif", "/lp/community-2.avif", "/lp/community-1.avif"] +let firstImage: ImageAsset.t = { + src: "/lp/community-3.avif", + width: 1000, + height: 667, + source: Original, +} +let images: array = [ + firstImage, + {src: "/lp/community-2.avif", width: 1355, height: 904, source: Original}, + {src: "/lp/community-1.avif", width: 1000, height: 667, source: Original}, +] test( "gallery selectors show their photo and the next control wraps after the last photo", async () => { - let screen = await render() + let screen = await render() let first = await screen->getByLabelText("Show community photo 1") let third = await screen->getByLabelText("Show community photo 3") let next = await screen->getByLabelText("Next community photo") @@ -25,18 +35,18 @@ test( ) test("gallery renders no controls for an empty image list", async () => { - let screen = await render() + let screen = await render() expect(screen->container->textContent->Nullable.toOption)->toEqual(Some("")) let next = await screen->getByLabelText("Next community photo") await element(next)->notToBeInTheDocument }) test("gallery returns to the first image when the selected image is removed", async () => { - let screen = await render() + let screen = await render() let third = await screen->getByLabelText("Show community photo 3") await third->click - await screen->rerender() + await screen->rerender() let firstImage = await screen->getByAltText("ReScript community photo 1") await element(firstImage)->toHaveAttribute("src", "/lp/community-3.avif") @@ -46,15 +56,15 @@ test("gallery returns to the first image when the selected image is removed", as }) test("gallery keeps the first image selected when a shortened list grows again", async () => { - let screen = await render() + let screen = await render() let third = await screen->getByLabelText("Show community photo 3") await third->click - await screen->rerender() + await screen->rerender() let first = await screen->getByLabelText("Show community photo 1") await element(first)->toHaveAttribute("aria-pressed", "true") - await screen->rerender() + await screen->rerender() await element(first)->toHaveAttribute("aria-pressed", "true") await element(third)->toHaveAttribute("aria-pressed", "false") @@ -63,15 +73,15 @@ test("gallery keeps the first image selected when a shortened list grows again", }) test("gallery keeps the first image selected after an empty list is restored", async () => { - let screen = await render() + let screen = await render() let third = await screen->getByLabelText("Show community photo 3") await third->click - await screen->rerender() + await screen->rerender() let next = await screen->getByLabelText("Next community photo") await element(next)->notToBeInTheDocument - await screen->rerender() + await screen->rerender() let first = await screen->getByLabelText("Show community photo 1") await element(first)->toHaveAttribute("aria-pressed", "true") diff --git a/apps/docs/__tests__/LandingPageVideo_.test.res b/apps/docs/__tests__/LandingPageVideo_.test.res new file mode 100644 index 000000000..a71fa3fd6 --- /dev/null +++ b/apps/docs/__tests__/LandingPageVideo_.test.res @@ -0,0 +1,28 @@ +open Vitest + +let attribute = (selector, name) => + document + ->WebAPI.Document.querySelector(selector) + ->Null.toOption + ->Option.flatMap(element => element->WebAPI.Element.getAttribute(name)->Null.toOption) + +test( + "homepage videos reserve dimensions and defer media loading while retaining controls", + async () => { + let _screen = await render( + , + ) + expect(attribute("video", "preload"))->toEqual(Some("none")) + expect(attribute("video", "width"))->toEqual(Some("1750")) + expect(attribute("video", "height"))->toEqual(Some("1116")) + expect(attribute("video", "poster"))->toEqual(Some("/lp/fast-build-preview.avif")) + expect(attribute("video", "controls"))->toEqual(Some("")) + expect(attribute("video source", "src"))->toEqual( + Some("https://assets-17077.kxcdn.com/videos/fast-build-3.mp4"), + ) + expect(attribute("video source", "type"))->toEqual(Some("video/mp4")) + }, +) diff --git a/apps/docs/__tests__/ResponsiveImage_.test.res b/apps/docs/__tests__/ResponsiveImage_.test.res new file mode 100644 index 000000000..94f46c6d6 --- /dev/null +++ b/apps/docs/__tests__/ResponsiveImage_.test.res @@ -0,0 +1,122 @@ +open Vitest + +let metadata: ImageAsset.metadata = { + availableWidths: [360, 640, 1000], + imageUrlFor: (width, format) => { + let format = switch format { + | #avif => "avif" + | #webp => "webp" + } + Some(`/lp/community-3.avif?width=${width->Int.toString}&format=${format}`) + }, +} + +let original: ImageAsset.t = { + src: "/lp/community-3.avif", + width: 1000, + height: 667, + source: Original, +} + +// Source elements have no accessible role; inspect their native media-selection attributes. +let sourceAttribute = name => + document + ->WebAPI.Document.querySelector("picture source") + ->Null.toOption + ->Option.flatMap(source => source->WebAPI.Element.getAttribute(name)->Null.toOption) + +let imageAttribute = name => + document + ->WebAPI.Document.querySelector("img") + ->Null.toOption + ->Option.flatMap(image => image->WebAPI.Element.getAttribute(name)->Null.toOption) + +test("original images retain a plain image with dimensions and loading attributes", async () => { + let screen = await render( + , + ) + let image = await screen->getByAltText("Community photo") + await element(image)->toHaveAttribute("src", "/lp/community-3.avif") + await element(image)->toHaveAttribute("width", "1000") + await element(image)->toHaveAttribute("height", "667") + await element(image)->toHaveAttribute("loading", "lazy") + await element(image)->toHaveAttribute("decoding", "async") + expect(document->WebAPI.Document.querySelector("picture")->Null.toOption)->toEqual(None) + expect(imageAttribute("srcset"))->toEqual(None) +}) + +test("responsive images render one AVIF source and a sized WebP image fallback", async () => { + let screen = await render( + , + ) + let image = await screen->getByAltText("Responsive community photo") + await element(image)->toHaveAttribute("src", "/lp/community-3.avif?width=1000&format=webp") + await element(image)->toHaveAttribute( + "srcset", + "/lp/community-3.avif?width=360&format=webp 360w, /lp/community-3.avif?width=640&format=webp 640w, /lp/community-3.avif?width=1000&format=webp 1000w", + ) + await element(image)->toHaveAttribute("sizes", "(min-width: 768px) 623.2px, 622px") + await element(image)->toHaveAttribute("width", "1000") + await element(image)->toHaveAttribute("height", "667") + await element(image)->toHaveAttribute("loading", "eager") + await element(image)->toHaveClass("gallery-photo") + expect(sourceAttribute("type"))->toEqual(Some("image/avif")) + expect(sourceAttribute("sizes"))->toEqual(Some("(min-width: 768px) 623.2px, 622px")) + expect(sourceAttribute("srcset"))->toEqual( + Some( + "/lp/community-3.avif?width=360&format=avif 360w, /lp/community-3.avif?width=640&format=avif 640w, /lp/community-3.avif?width=1000&format=avif 1000w", + ), + ) + expect(document->WebAPI.Document.querySelector("source ~ source")->Null.toOption)->toEqual(None) +}) + +test( + "unavailable responsive URLs render the valid original without empty source sets", + async () => { + let missing = {...metadata, imageUrlFor: (_width, _format) => None} + let screen = await render( + , + ) + let image = await screen->getByAltText("Fallback photo") + await element(image)->toHaveAttribute("src", "/lp/community-3.avif") + expect(imageAttribute("srcset"))->toEqual(None) + expect(sourceAttribute("srcset"))->toEqual(None) + expect(document->WebAPI.Document.querySelector("picture")->Null.toOption)->toEqual(None) + }, +) + +test( + "real plugin metadata renders generated assets with the requested image dimensions", + async () => { + let screen = await render( + , + ) + let image = await screen->getByAltText("ReScript editor tooling") + await element(image)->toHaveAttribute("width", "1000") + await element(image)->toHaveAttribute("height", "497") + await element(image)->toHaveAttribute("sizes", LandingPageImages.screenshotSizes) + expect(sourceAttribute("type"))->toEqual(Some("image/avif")) + let generatedWidths = switch LandingPageImages.tooling.source { + | Original => [] + | Responsive(metadata) => metadata.availableWidths + } + expect(generatedWidths)->toEqual([360, 640, 1000]) + let hasGeneratedFallback = + imageAttribute("src")->Option.map(src => + src->String.includes("/@responsive-image/vite-plugin/webp/1000/") + ) + expect(hasGeneratedFallback)->toEqual(Some(true)) + }, +) diff --git a/apps/docs/app/routes/LandingPageVideo.res b/apps/docs/app/routes/LandingPageVideo.res new file mode 100644 index 000000000..202d98508 --- /dev/null +++ b/apps/docs/app/routes/LandingPageVideo.res @@ -0,0 +1,6 @@ +@react.component +let make = (~poster, ~src) => + NativeMedia.createVideo( + {className: "rounded-lg", controls: true, poster, width: 1750, height: 1116, preload: #none}, + , + ) diff --git a/apps/docs/app/routes/LandingPageVideo.resi b/apps/docs/app/routes/LandingPageVideo.resi new file mode 100644 index 000000000..41540aeae --- /dev/null +++ b/apps/docs/app/routes/LandingPageVideo.resi @@ -0,0 +1,3 @@ +/** Native video controls load media on demand without changing the poster's reserved layout. */ +@react.component +let make: (~poster: string, ~src: string) => React.element diff --git a/apps/docs/e2e-playwright/homepage-media.spec.mjs b/apps/docs/e2e-playwright/homepage-media.spec.mjs new file mode 100644 index 000000000..cb4b120a9 --- /dev/null +++ b/apps/docs/e2e-playwright/homepage-media.spec.mjs @@ -0,0 +1,213 @@ +import { expect, test } from "playwright/test"; +import { JSDOM } from "jsdom"; + +const responsiveNames = [ + "ReScript community photo 1", + "ReScript editor tooling", + "ReScript JavaScript output", +]; + +function candidates(sourceSet) { + return sourceSet.split(",").map((candidate) => { + const [url, width] = candidate.trim().split(/\s+/); + return { url, width: Number.parseInt(width, 10) }; + }); +} + +function imagePreloadUrls(document) { + return [ + ...document.querySelectorAll('link[rel="preload"][as="image"]'), + ].flatMap((link) => { + const sourceSet = link.getAttribute("imagesrcset"); + return [ + link.getAttribute("href"), + ...(sourceSet === null + ? [] + : candidates(sourceSet).map((entry) => entry.url)), + ]; + }); +} + +async function expectAvailableCandidates( + request, + { element, format, preloadedImages }, +) { + const entries = candidates(element?.getAttribute("srcset") ?? ""); + expect(entries.map((entry) => entry.width)).toEqual([360, 640, 1000]); + for (const entry of entries) { + expect(preloadedImages).not.toContain(entry.url); + const asset = await request.get(entry.url); + expect(asset.ok(), entry.url).toBe(true); + expect(asset.headers()["content-type"], entry.url).toContain( + `image/${format}`, + ); + expect((await asset.body()).byteLength, entry.url).toBeGreaterThan(0); + } +} + +async function decodeHomepageImages(page) { + for (const section of await page.locator("main section").all()) { + await section.scrollIntoViewIfNeeded(); + } + await page.locator("img").evaluateAll(async (images) => { + await Promise.all(images.map((image) => image.decode())); + await document.fonts.ready; + }); +} + +async function mediaBounds(page) { + await page.evaluate(() => document.fonts.ready); + return page.locator("main img, main video").evaluateAll((elements) => + elements.map((element) => { + const bounds = element.getBoundingClientRect(); + const hasBox = bounds.width !== 0 || bounds.height !== 0; + return { + width: bounds.width, + height: bounds.height, + top: hasBox ? bounds.top + window.scrollY : 0, + }; + }), + ); +} + +async function expectSelectedCandidate(image, expectedWidth, pageUrl) { + await image.scrollIntoViewIfNeeded(); + await image.evaluate((element) => element.decode()); + const selection = await image.evaluate((element) => ({ + currentSrc: element.currentSrc, + avif: element.parentElement.querySelector("source").srcset, + renderedWidth: element.getBoundingClientRect().width, + })); + const selected = candidates(selection.avif).find( + (entry) => new URL(entry.url, pageUrl).href === selection.currentSrc, + ); + expect(selected).toBeDefined(); + expect(selected?.width).toBe(expectedWidth); + expect(selected?.width).toBeGreaterThanOrEqual(selection.renderedWidth); +} + +test("prerendered homepage reserves dimensions for every image and video", async ({ + request, +}) => { + const response = await request.get("/"); + expect(response.ok()).toBe(true); + const { document } = new JSDOM(await response.text()).window; + const images = [...document.querySelectorAll("img")]; + const videos = [...document.querySelectorAll("video")]; + + expect(images).toHaveLength(63); + expect(videos).toHaveLength(3); + for (const element of [...images, ...videos]) { + const source = + element.getAttribute("src") ?? element.getAttribute("poster"); + expect(Number(element.getAttribute("width")), source).toBeGreaterThan(0); + expect(Number(element.getAttribute("height")), source).toBeGreaterThan(0); + } + for (const video of videos) { + expect(video.getAttribute("preload")).toBe("none"); + } +}); + +test("prerendered responsive media exposes valid AVIF and WebP candidates", async ({ + request, +}) => { + const response = await request.get("/"); + expect(response.ok()).toBe(true); + const { document } = new JSDOM(await response.text()).window; + const pictures = [...document.querySelectorAll("picture")]; + expect(pictures).toHaveLength(3); + const preloadedImages = imagePreloadUrls(document); + + for (const picture of pictures) { + const sources = [...picture.querySelectorAll("source")]; + expect(sources).toHaveLength(1); + const source = sources.at(0); + const image = picture.querySelector("img"); + expect(source?.getAttribute("type")).toBe("image/avif"); + expect(image?.getAttribute("loading")).toBe("lazy"); + expect(image?.getAttribute("decoding")).toBe("async"); + expect(image?.getAttribute("sizes")).toEqual(expect.any(String)); + expect(image?.getAttribute("sizes")).toBe(source?.getAttribute("sizes")); + expect(image?.getAttribute("sizes")).not.toBe(""); + + for (const [element, format] of [ + [source, "avif"], + [image, "webp"], + ]) { + await expectAvailableCandidates(request, { + element, + format, + preloadedImages, + }); + } + const fallback = await request.get(image?.getAttribute("src") ?? ""); + expect(fallback.ok()).toBe(true); + expect(fallback.headers()["content-type"]).toContain("image/webp"); + } +}); + +for (const width of [375, 1440]) { + test(`responsive images select appropriately sized files at ${width}px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto("/"); + + for (const name of responsiveNames) { + const image = page.getByRole("img", { name, exact: true }); + const expectedWidth = + width === 375 && name !== "ReScript community photo 1" ? 360 : 640; + await expectSelectedCandidate(image, expectedWidth, page.url()); + } + + for (const index of [2, 3]) { + await page + .getByRole("button", { name: `Show community photo ${index}` }) + .click(); + const image = page.getByRole("img", { + name: `ReScript community photo ${index}`, + exact: true, + }); + await expectSelectedCandidate(image, 640, page.url()); + await expect(image).toBeVisible(); + await expect(image).toHaveAttribute( + "width", + index === 2 ? "1355" : "1000", + ); + await expect(image).toHaveAttribute( + "height", + index === 2 ? "904" : "667", + ); + } + }); + + test(`homepage media reserves its loaded layout before image responses at ${width}px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + const responses = Promise.withResolvers(); + await page.route("**/*", async (route) => { + if (route.request().resourceType() === "image") { + await responses.promise; + } + await route.continue(); + }); + try { + await page.goto("/", { waitUntil: "domcontentloaded" }); + const reserved = await mediaBounds(page); + responses.resolve(); + await decodeHomepageImages(page); + const loaded = await mediaBounds(page); + + expect(loaded).toHaveLength(reserved.length); + loaded.forEach((bounds, index) => { + expect(bounds.width).toBeCloseTo(reserved[index].width, 0); + expect(bounds.height).toBeCloseTo(reserved[index].height, 0); + expect(bounds.top).toBeCloseTo(reserved[index].top, 0); + }); + } finally { + responses.resolve(); + await page.unrouteAll({ behavior: "wait" }); + } + }); +} diff --git a/apps/docs/package.json b/apps/docs/package.json index 92df89187..7ed76418c 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -79,6 +79,8 @@ }, "devDependencies": { "@react-router/dev": "^8.3.1", + "@responsive-image/core": "2.1.0", + "@responsive-image/vite-plugin": "3.0.1", "@tailwindcss/vite": "^4.3.0", "@types/react": "^19.2.14", "@vitejs/plugin-react": "^6.0.1", diff --git a/apps/docs/src/bindings/NativeMedia.res b/apps/docs/src/bindings/NativeMedia.res new file mode 100644 index 000000000..38230a2f4 --- /dev/null +++ b/apps/docs/src/bindings/NativeMedia.res @@ -0,0 +1,27 @@ +type imageProps = { + className: string, + alt: string, + src: string, + srcSet: option, + sizes: string, + width: int, + height: int, + loading: option<[#eager | #lazy]>, + decoding: [#async], +} + +type videoProps = { + className: string, + controls: bool, + poster: string, + width: int, + height: int, + preload: [#none], +} + +// The installed JSX DOM bindings omit the native decoding and preload attributes. +@module("react") +external createImage: (@as("img") _, imageProps) => React.element = "createElement" + +@module("react") +external createVideo: (@as("video") _, videoProps, React.element) => React.element = "createElement" diff --git a/apps/docs/src/common/ImageAsset.res b/apps/docs/src/common/ImageAsset.res new file mode 100644 index 000000000..eb7a969d9 --- /dev/null +++ b/apps/docs/src/common/ImageAsset.res @@ -0,0 +1,31 @@ +type format = [#avif | #webp] + +// Local plugin imports always supply widths; remote image-service metadata is not accepted. +type metadata = { + availableWidths: array, + imageUrlFor: (int, format) => option, +} + +type source = Original | Responsive(metadata) + +type t = {src: string, width: int, height: int, source: source} + +let imageUrl = (metadata, width, format) => + metadata.imageUrlFor(width, format)->Option.filter(url => url->String.trim !== "") + +let sourceSet = (metadata, format) => { + let candidates = + metadata.availableWidths->Array.filterMap(width => + imageUrl(metadata, width, format)->Option.map(url => `${url} ${width->Int.toString}w`) + ) + switch candidates { + | [] => None + | _ => Some(candidates->Array.join(", ")) + } +} + +let fallback = image => + switch image.source { + | Original => image.src + | Responsive(metadata) => imageUrl(metadata, image.width, #webp)->Option.getOr(image.src) + } diff --git a/apps/docs/src/common/ImageAsset.resi b/apps/docs/src/common/ImageAsset.resi new file mode 100644 index 000000000..a38904e66 --- /dev/null +++ b/apps/docs/src/common/ImageAsset.resi @@ -0,0 +1,10 @@ +type format = [#avif | #webp] +type metadata = { + availableWidths: array, + imageUrlFor: (int, format) => option, +} +type source = Original | Responsive(metadata) +type t = {src: string, width: int, height: int, source: source} + +let sourceSet: (metadata, format) => option +let fallback: t => string diff --git a/apps/docs/src/components/Footer.res b/apps/docs/src/components/Footer.res index b3897faf3..d2aa542d0 100644 --- a/apps/docs/src/components/Footer.res +++ b/apps/docs/src/components/Footer.res @@ -23,7 +23,13 @@ let make = () => { className="flex flex-col md:flex-row justify-between max-w-1280 w-full px-8 py-16 text-gray-80 " >
- + ReScript

{React.string(`© ${copyrightYear} The ReScript Project`)}

diff --git a/apps/docs/src/components/ImageGallery.res b/apps/docs/src/components/ImageGallery.res index c74b5f7f3..8197711de 100644 --- a/apps/docs/src/components/ImageGallery.res +++ b/apps/docs/src/components/ImageGallery.res @@ -1,16 +1,22 @@ @react.component -let make = (~className="", ~imgClassName="", ~imgSrcs: array, ~imgLoading=?) => { +let make = ( + ~className="", + ~imgClassName="", + ~images: array, + ~imgSizes="100vw", + ~imgLoading=?, +) => { let (selected, setSelected) = React.useState(_ => 0) - let count = Array.length(imgSrcs) + let count = Array.length(images) let index = selected < count ? selected : 0 if selected !== index { setSelected(_ => index) } - switch imgSrcs->Array.get(index) { + switch images->Array.get(index) { | None => React.null - | Some(src) => + | Some(image) =>
- {imgSrcs - ->Array.mapWithIndex((src, i) => { + {images + ->Array.mapWithIndex((image, i) => { let color = i === index ? "text-gray-40" : "text-gray-70"
@@ -69,9 +72,10 @@ let make = () => { - - } + media={} paragraph={<>

{React.string(`ReScript cares about a consistent and fast @@ -90,9 +94,10 @@ let make = () => { > {React.string("Type Better")} } - media={} + media={} polygonDirection=Up paragraph={React.string(`Every ReScript app is fully typed and provides reliable type information for any given value in your program. We @@ -107,13 +112,10 @@ let make = () => { {React.string("The familiar JS ecosystem")} {React.string(" at your fingertips")} } - media={} + media={} paragraph={React.string(`Use any library from JavaScript, export ReScript libraries to JavaScript, automatically generate TypeScript types. It's like you've never left the good parts of JavaScript at all.`)} diff --git a/apps/docs/src/components/LandingPageMainSellingPoints.resi b/apps/docs/src/components/LandingPageMainSellingPoints.resi index 1ca44ce26..936c3763f 100644 --- a/apps/docs/src/components/LandingPageMainSellingPoints.resi +++ b/apps/docs/src/components/LandingPageMainSellingPoints.resi @@ -1,2 +1,3 @@ +/** Video and decoration dimensions keep the selling-point layout stable while media loads. */ @react.component let make: unit => React.element diff --git a/apps/docs/src/components/LandingPageOtherSellingPoints.res b/apps/docs/src/components/LandingPageOtherSellingPoints.res index cd36192dc..355ec0177 100644 --- a/apps/docs/src/components/LandingPageOtherSellingPoints.res +++ b/apps/docs/src/components/LandingPageOtherSellingPoints.res @@ -10,7 +10,8 @@ let make = () => {

@@ -30,8 +31,12 @@ let make = () => {

- ReScript editor tooling

{React.string(`Tooling that just works out of the box`)} @@ -44,7 +49,13 @@ let make = () => {

- +

{React.string(`Easy to adopt — without any lock-in`)}

diff --git a/apps/docs/src/components/LandingPageOtherSellingPoints.resi b/apps/docs/src/components/LandingPageOtherSellingPoints.resi index 1ca44ce26..b4c649523 100644 --- a/apps/docs/src/components/LandingPageOtherSellingPoints.resi +++ b/apps/docs/src/components/LandingPageOtherSellingPoints.resi @@ -1,2 +1,3 @@ +/** Responsive community photos and screenshots with reserved image dimensions. */ @react.component let make: unit => React.element diff --git a/apps/docs/src/components/LandingPagePlayground.res b/apps/docs/src/components/LandingPagePlayground.res index cd43452c1..6a7ed2bd0 100644 --- a/apps/docs/src/components/LandingPagePlayground.res +++ b/apps/docs/src/components/LandingPagePlayground.res @@ -45,14 +45,36 @@ let make = (~playgroundData: playgroundData) => { {React.string("Edit this example in Playground")}
- - + +
+ -
diff --git a/apps/docs/src/components/LandingPagePlayground.resi b/apps/docs/src/components/LandingPagePlayground.resi index fa01d2021..5e349f0b4 100644 --- a/apps/docs/src/components/LandingPagePlayground.resi +++ b/apps/docs/src/components/LandingPagePlayground.resi @@ -4,6 +4,6 @@ type playgroundData = { playgroundHref: string, } -/** Render only trusted highlighted markup produced by the homepage loader. */ +/** Render trusted loader markup and reserve space for the decorative images. */ @react.component let make: (~playgroundData: playgroundData) => React.element diff --git a/apps/docs/src/components/LandingPageTrustedBy.res b/apps/docs/src/components/LandingPageTrustedBy.res index 3c6003069..0ee3d8d91 100644 --- a/apps/docs/src/components/LandingPageTrustedBy.res +++ b/apps/docs/src/components/LandingPageTrustedBy.res @@ -12,9 +12,16 @@ let make = () => { {OurUsers.companies ->Array.map(company => switch company { - | Logo({name, path, url}) => + | Logo({name, path, url, width, height}) => - + nameInt.toString} + height={height->Int.toString} + loading=#lazy + /> } ) @@ -25,6 +32,12 @@ let make = () => { > - + } diff --git a/apps/docs/src/components/LandingPageTrustedBy.resi b/apps/docs/src/components/LandingPageTrustedBy.resi index 1ca44ce26..e0905fb23 100644 --- a/apps/docs/src/components/LandingPageTrustedBy.resi +++ b/apps/docs/src/components/LandingPageTrustedBy.resi @@ -1,2 +1,3 @@ +/** Company logos retain their intrinsic aspect ratio at the existing display height. */ @react.component let make: unit => React.element diff --git a/apps/docs/src/components/NavbarPrimary.res b/apps/docs/src/components/NavbarPrimary.res index a2df0e63f..5f586b3f5 100644 --- a/apps/docs/src/components/NavbarPrimary.res +++ b/apps/docs/src/components/NavbarPrimary.res @@ -17,10 +17,18 @@ module LeftContent = { > ReScript Home ReScript Home diff --git a/apps/docs/src/components/ResponsiveImage.res b/apps/docs/src/components/ResponsiveImage.res new file mode 100644 index 000000000..81699d6bf --- /dev/null +++ b/apps/docs/src/components/ResponsiveImage.res @@ -0,0 +1,30 @@ +@react.component +let make = (~image: ImageAsset.t, ~sizes, ~alt, ~className="", ~loading=?) => { + let (avifSrcSet, webpSrcSet) = switch image.source { + | Original => (None, None) + | Responsive(metadata) => ( + ImageAsset.sourceSet(metadata, #avif), + ImageAsset.sourceSet(metadata, #webp), + ) + } + let img = NativeMedia.createImage({ + className, + alt, + src: ImageAsset.fallback(image), + srcSet: webpSrcSet, + sizes, + width: image.width, + height: image.height, + loading, + decoding: #async, + }) + + switch avifSrcSet { + | None => img + | Some(srcSet) => + + + img + + } +} diff --git a/apps/docs/src/components/ResponsiveImage.resi b/apps/docs/src/components/ResponsiveImage.resi new file mode 100644 index 000000000..7aa677f5a --- /dev/null +++ b/apps/docs/src/components/ResponsiveImage.resi @@ -0,0 +1,8 @@ +@react.component +let make: ( + ~image: ImageAsset.t, + ~sizes: string, + ~alt: string, + ~className: string=?, + ~loading: [#eager | #lazy]=?, +) => React.element diff --git a/apps/docs/src/data/LandingPageImages.res b/apps/docs/src/data/LandingPageImages.res new file mode 100644 index 000000000..d9b6c9e2a --- /dev/null +++ b/apps/docs/src/data/LandingPageImages.res @@ -0,0 +1,50 @@ +// The url marker permits public-file imports; the responsive plugin supplies their metadata. +@module( + "../../public/lp/community-3.avif?url&w=360;640;1000&format=avif;webp&quality=70&responsive" +) +external community3: ImageAsset.metadata = "default" + +@module( + "../../public/lp/community-2.avif?url&w=360;640;1000&format=avif;webp&quality=70&responsive" +) +external community2: ImageAsset.metadata = "default" + +@module( + "../../public/lp/community-1.avif?url&w=360;640;1000&format=avif;webp&quality=70&responsive" +) +external community1: ImageAsset.metadata = "default" + +@module( + "../../public/lp/editor-tooling-1.avif?url&w=360;640;1000&format=avif;webp&quality=70&responsive" +) +external toolingImage: ImageAsset.metadata = "default" + +@module( + "../../public/lp/easy-to-unadopt.avif?url&w=360;640;1000&format=avif;webp&quality=70&responsive" +) +external unadoptImage: ImageAsset.metadata = "default" + +let community: array = [ + {src: "/lp/community-3.avif", width: 1000, height: 667, source: Responsive(community3)}, + {src: "/lp/community-2.avif", width: 1355, height: 904, source: Responsive(community2)}, + {src: "/lp/community-1.avif", width: 1000, height: 667, source: Responsive(community1)}, +] + +let tooling: ImageAsset.t = { + src: "/lp/editor-tooling-1.avif", + width: 1000, + height: 497, + source: Responsive(toolingImage), +} + +let unadopt: ImageAsset.t = { + src: "/lp/easy-to-unadopt.avif", + width: 1000, + height: 486, + source: Responsive(unadoptImage), +} + +// Match the grid's column widths; the fixed-height crop needs at least 622px of image width. +let communitySizes = "(min-width: 1188px) 623.2px, (min-width: 1024px) max(622px, calc(60vw - 89.6px)), (min-width: 768px) max(622px, calc(60vw - 51.2px)), (min-width: 576px) max(622px, calc(100vw - 64px)), max(622px, calc(100vw - 32px))" +// The screenshots occupy four columns of the same grid, without cropping. +let screenshotSizes = "(min-width: 1188px) 404.8px, (min-width: 1024px) calc(40vw - 70.4px), (min-width: 768px) calc(40vw - 44.8px), (min-width: 576px) calc(100vw - 64px), calc(100vw - 32px)" diff --git a/apps/docs/src/data/LandingPageImages.resi b/apps/docs/src/data/LandingPageImages.resi new file mode 100644 index 000000000..97be31553 --- /dev/null +++ b/apps/docs/src/data/LandingPageImages.resi @@ -0,0 +1,5 @@ +let community: array +let tooling: ImageAsset.t +let unadopt: ImageAsset.t +let communitySizes: string +let screenshotSizes: string diff --git a/apps/docs/src/data/OurUsers.res b/apps/docs/src/data/OurUsers.res index 7ee60a3d5..4de9618aa 100644 --- a/apps/docs/src/data/OurUsers.res +++ b/apps/docs/src/data/OurUsers.res @@ -1,217 +1,302 @@ -type company = Logo({name: string, url: string, path: string}) +type company = Logo({name: string, url: string, path: string, width: int, height: int}) // NOTE: More details on how this works can be found in our README: // https://github.com/rescript-lang/rescript-lang.org#how-to-add-your-company-logo-to-our-front-page +// Fractional SVG dimensions are scaled to integer units to preserve their exact aspect ratio. let companies = [ Logo({ name: "Rohea", url: "https://rohea.com", path: "/lp/rohea.svg", + width: 155, + height: 48, }), Logo({ name: "CCA", url: "https://cca.io", path: "/lp/cca-io.svg", + width: 60, + height: 48, }), Logo({ name: "Nomadic Labs", url: "https://nomadic-labs.com", path: "/lp/nomadic_labs.svg", + width: 188, + height: 48, }), Logo({ name: "Draftbit", url: "https://draftbit.com", path: "/lp/draftbit.svg", + width: 157, + height: 48, }), Logo({ name: "Porter", url: "https://porter.in", path: "/lp/porter.svg", + width: 80, + height: 20, }), Logo({ name: "Pupilfirst", url: "https://pupilfirst.com", path: "/lp/pupilfirst.svg", + width: 176, + height: 48, }), Logo({ name: "Aivero", url: "https://aivero.com", path: "/lp/aivero.svg", + width: 1105787, + height: 409501, }), Logo({ name: "Walnut", url: "https://walnut.io", path: "/lp/walnut.svg", + width: 120, + height: 40, }), Logo({ name: "Collective Audience", url: "https://beop.collectiveaudience.co/", path: "/lp/collectiveaudience.svg", + width: 53772, + height: 21590, }), Logo({ name: "Arizon", url: "https://arizon.se", path: "/lp/arizon.svg", + width: 290, + height: 100, }), Logo({ name: "Stencil", url: "https://usestencil.com", path: "/lp/stencil.svg", + width: 5454667, + height: 1553333, }), Logo({ name: "Tiny", url: "https://tiny.cloud", path: "/lp/tiny.svg", + width: 126, + height: 48, }), Logo({ name: "Komplio", url: "https://kompl.io", path: "/lp/komplio.svg", + width: 20190, + height: 7998, }), Logo({ name: "ohne-makler.net", url: "https://www.ohne-makler.net", path: "/lp/ohne-makler.svg", + width: 4252, + height: 1389, }), Logo({ name: "Wino", url: "https://wino.fr", path: "/lp/wino.svg", + width: 60, + height: 20, }), Logo({ name: "Humaans", url: "https://humaans.io", path: "/lp/humaans.svg", + width: 385, + height: 102, }), Logo({ name: "SeaMonster Studios", url: "https://seamonsterstudios.com", path: "/lp/seamonster-studios.svg", + width: 190, + height: 43, }), Logo({ name: "Camelo", url: "https://camelohq.com", path: "/lp/camelo.svg", + width: 938, + height: 222, }), Logo({ name: "DevIT Jobs", url: "https://devitjobs.us", path: "/lp/dev-it-jobs.svg", + width: 400, + height: 150, }), Logo({ name: "Sanjagh", url: "https://sanjagh.pro", path: "/lp/sanjagh.svg", + width: 96, + height: 48, }), Logo({ name: "Carla", url: "https://carla.se", path: "/lp/carla.svg", + width: 368, + height: 92, }), Logo({ name: "Reka.Market", url: "https://reka.market", path: "/lp/reka_market.svg", + width: 130, + height: 51, }), Logo({ name: "Better Cart", url: "https://getbettercart.com", path: "/lp/bettercart.svg", + width: 2482, + height: 428, }), Logo({ name: "Greenlabs", url: "https://greenlabs.co.kr/", path: "/lp/greenlabs.svg", + width: 4152, + height: 1306, }), Logo({ name: "Maker", url: "https://maker.co/", path: "/lp/maker.svg", + width: 326, + height: 65, }), Logo({ name: "Darklang", url: "https://darklang.com/", path: "/lp/darklang.svg", + width: 1340, + height: 480, }), Logo({ name: "Better Vim", url: "https://bettervim.com/", path: "/lp/bettervim.svg", + width: 140, + height: 35, }), Logo({ name: "Instapainting.com", url: "https://www.instapainting.com/", path: "/lp/instapainting.avif", + width: 1000, + height: 167, }), Logo({ name: "Juspay", url: "https://juspay.in", path: "/lp/juspay.svg", + width: 160, + height: 48, }), Logo({ name: "Silq", url: "https://www.onesilq.com/", path: "/lp/silq.svg", + width: 11004, + height: 4663, }), Logo({ name: "Sensonomic", url: "https://www.sensonomic.com/", path: "/lp/sensonomic.svg", + width: 330, + height: 48, }), Logo({ name: "Band Protocol", url: "https://www.bandprotocol.com/", path: "/lp/bandprotocol.svg", + width: 1080, + height: 289, }), Logo({ name: "Envio", url: "https://www.envio.dev/", path: "/lp/envio.svg", + width: 591, + height: 185, }), Logo({ name: "YBRU", url: "https://ybru.ru", path: "/lp/ybru.svg", + width: 107, + height: 24, }), Logo({ name: "WebCurate", url: "https://webcurate.co", path: "/lp/webcurate.svg", + width: 1697, + height: 291, }), Logo({ name: "Resmume", url: "https://resmume.com/", path: "/lp/resmume.svg", + width: 5891, + height: 1055, }), Logo({ name: "Cardoc", url: "https://www.cardoc.co.kr/", path: "/lp/cardoc.svg", + width: 821, + height: 191, }), Logo({ name: "Frontman", url: "https://frontman.sh", path: "/lp/frontman.svg", + width: 120, + height: 48, }), Logo({ name: "miriad", url: "https://www.miriad.studio/", path: "/lp/miriad.svg", + width: 312800, + height: 76001, }), Logo({ name: "Cangjie Workbook", url: "https://www.cangjieworkbook.com/", path: "/lp/cangjieworkbook.svg", + width: 683, + height: 240, }), Logo({ name: "ReactiveNET", url: "https://reactivenet.ai", path: "/lp/reactivenet.svg", + width: 1620, + height: 312, }), Logo({ name: "LMDmax", url: "https://lmdmax.com", path: "/lp/lmdmax.svg", + width: 145, + height: 32, }), ] diff --git a/apps/docs/vite.config.mjs b/apps/docs/vite.config.mjs index 41223e924..9ad4544bb 100644 --- a/apps/docs/vite.config.mjs +++ b/apps/docs/vite.config.mjs @@ -1,4 +1,5 @@ import { reactRouter } from "@react-router/dev/vite"; +import { responsiveImage } from "@responsive-image/vite-plugin"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; @@ -16,6 +17,7 @@ export default defineConfig({ global: "globalThis", }, plugins: [ + responsiveImage(), tailwindcss(), reactRouter(), react({ diff --git a/apps/docs/vitest.config.mjs b/apps/docs/vitest.config.mjs index 0d24589ec..427123952 100644 --- a/apps/docs/vitest.config.mjs +++ b/apps/docs/vitest.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config"; import { playwright } from "@vitest/browser-playwright"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import { responsiveImage } from "@responsive-image/vite-plugin"; const isUpdatingSnapshots = process.argv.some( (arg) => arg === "-u" || arg === "--update" || arg.startsWith("--update="), @@ -17,6 +18,7 @@ if (isUpdatingSnapshots && !canUpdateVisualBaselines) { } const setupDeps = [ + "@responsive-image/core", "highlight.js/lib/core", "highlight.js/lib/languages/bash", "highlight.js/lib/languages/css", @@ -33,7 +35,7 @@ const setupDeps = [ export default defineConfig({ envDir: "../..", - plugins: [react(), tailwindcss()], + plugins: [responsiveImage(), react(), tailwindcss()], optimizeDeps: { include: setupDeps, }, diff --git a/yarn.lock b/yarn.lock index f5bcbd4e8..ccb3767be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1939,6 +1939,8 @@ __metadata: "@rescript-lang/shared": "workspace:*" "@rescript/react": "npm:^0.14.2" "@rescript/webapi": "npm:0.1.0-experimental-29db5f4" + "@responsive-image/core": "npm:2.1.0" + "@responsive-image/vite-plugin": "npm:3.0.1" "@tailwindcss/vite": "npm:^4.3.0" "@types/react": "npm:^19.2.14" "@vitejs/plugin-react": "npm:^6.0.1" @@ -2139,6 +2141,53 @@ __metadata: languageName: node linkType: hard +"@responsive-image/build-utils@npm:^3.0.1": + version: 3.0.1 + resolution: "@responsive-image/build-utils@npm:3.0.1" + dependencies: + "@responsive-image/core": "npm:^2.1.0" + "@rollup/pluginutils": "npm:^5.1.0" + base-n: "npm:^3.0.0" + imagetools-core: "npm:^9.0.0" + javascript-stringify: "npm:^2.1.0" + sharp: "npm:^0.35.0" + checksum: 10c0/8fd69a13fe5aced1a00fb87ec9c1e6d581818075de8eb5aaf3a8a7bcd96ac0784c2ddfd7665c6a90b5ce065848511c5f68d238f3c6129025fd5dcea3d8cea2f0 + languageName: node + linkType: hard + +"@responsive-image/core@npm:2.1.0, @responsive-image/core@npm:^2.1.0": + version: 2.1.0 + resolution: "@responsive-image/core@npm:2.1.0" + dependencies: + base64-arraybuffer: "npm:^1.0.2" + peerDependencies: + blurhash: ^2.0.0 + thumbhash: ^0.1.1 + peerDependenciesMeta: + blurhash: + optional: true + thumbhash: + optional: true + checksum: 10c0/fc7ad5b800fa474145d3de43dac7f399fcc099212237ba9f6fa26cd4774d3dc15f394632f30522428c3e7c432811bdc058f06f87a29db4773226377b8a499d0d + languageName: node + linkType: hard + +"@responsive-image/vite-plugin@npm:3.0.1": + version: 3.0.1 + resolution: "@responsive-image/vite-plugin@npm:3.0.1" + dependencies: + "@responsive-image/build-utils": "npm:^3.0.1" + "@responsive-image/core": "npm:^2.1.0" + blurhash: "npm:^2.0.4" + imagetools-core: "npm:^9.0.0" + sharp: "npm:^0.35.0" + thumbhash: "npm:^0.1.1" + peerDependencies: + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: 10c0/e547f6d7d0f28e9f21e6f55181322e6b97b64fa9c1d6252cff63e9d09fcdff1923d46c7e3e827cbff5ec9528f4c9de1238267b6727414c8eb3289ddd708db0f0 + languageName: node + linkType: hard + "@rolldown/binding-android-arm-eabi@npm:1.2.8": version: 1.2.8 resolution: "@rolldown/binding-android-arm-eabi@npm:1.2.8" @@ -2258,6 +2307,22 @@ __metadata: languageName: node linkType: hard +"@rollup/pluginutils@npm:^5.1.0": + version: 5.4.0 + resolution: "@rollup/pluginutils@npm:5.4.0" + dependencies: + "@types/estree": "npm:^1.0.0" + estree-walker: "npm:^2.0.2" + picomatch: "npm:^4.0.2" + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10c0/ccc2cbd3a05df642df60ab05ffb81b2e564bd945e2a118bb8a474ea75b941033c8f44273133d4865643cca1492d0c80b14de1281f74779a64285a80fc3a194d8 + languageName: node + linkType: hard + "@sindresorhus/is@npm:^7.0.2": version: 7.1.1 resolution: "@sindresorhus/is@npm:7.1.1" @@ -3169,6 +3234,24 @@ __metadata: languageName: node linkType: hard +"base-n@npm:^3.0.0": + version: 3.0.0 + resolution: "base-n@npm:3.0.0" + dependencies: + dashdash: "npm:^1.14.1" + bin: + base-n: bin/cli.js + checksum: 10c0/a72c5fb54f46742f777c49a03e596680693f32d65800899f7f27cfbfbf278edf4c4fa56a46f99e9510dc630b71ca42519572b3cba7ba58cfe27f458c2da1e193 + languageName: node + linkType: hard + +"base64-arraybuffer@npm:^1.0.2": + version: 1.0.2 + resolution: "base64-arraybuffer@npm:1.0.2" + checksum: 10c0/3acac95c70f9406e87a41073558ba85b6be9dbffb013a3d2a710e3f2d534d506c911847d5d9be4de458af6362c676de0a5c4c2d7bdf4def502d00b313368e72f + languageName: node + linkType: hard + "base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -3229,6 +3312,13 @@ __metadata: languageName: node linkType: hard +"blurhash@npm:^2.0.4": + version: 2.0.5 + resolution: "blurhash@npm:2.0.5" + checksum: 10c0/75d5f042b5bb8ead977524a985048378a033af866534abbaee73c80030cc9fc34e6c5b7d483c68bbdb600e0ae438f2ea6140b1f46d75fcfa57a902208ae8b85e + languageName: node + linkType: hard + "body-parser@npm:1.20.3": version: 1.20.3 resolution: "body-parser@npm:1.20.3" @@ -3895,7 +3985,7 @@ __metadata: languageName: node linkType: hard -"dashdash@npm:^1.12.0": +"dashdash@npm:^1.12.0, dashdash@npm:^1.14.1": version: 1.14.1 resolution: "dashdash@npm:1.14.1" dependencies: @@ -4650,6 +4740,13 @@ __metadata: languageName: node linkType: hard +"estree-walker@npm:^2.0.2": + version: 2.0.2 + resolution: "estree-walker@npm:2.0.2" + checksum: 10c0/53a6c54e2019b8c914dc395890153ffdc2322781acf4bd7d1a32d7aedc1710807bdcd866ac133903d5629ec601fbb50abe8c2e5553c7f5a0afdd9b6af6c945af + languageName: node + linkType: hard + "estree-walker@npm:^3.0.0, estree-walker@npm:^3.0.3": version: 3.0.3 resolution: "estree-walker@npm:3.0.3" @@ -5665,6 +5762,13 @@ __metadata: languageName: node linkType: hard +"imagetools-core@npm:^9.0.0": + version: 9.1.0 + resolution: "imagetools-core@npm:9.1.0" + checksum: 10c0/6f0778c1233f4592241a40ebe71dc51a5bc74ca95e1ee84192fa260bbedc2e3afb25f87cb12ce138111ba1344186d1b4e0d0ed08801148ab40146c71b77b5f30 + languageName: node + linkType: hard + "import-meta-resolve@npm:^4.0.0": version: 4.2.0 resolution: "import-meta-resolve@npm:4.2.0" @@ -6209,6 +6313,13 @@ __metadata: languageName: node linkType: hard +"javascript-stringify@npm:^2.1.0": + version: 2.1.0 + resolution: "javascript-stringify@npm:2.1.0" + checksum: 10c0/374e74ebff29b94de78da39daa6e530999c58a145aeb293dc21180c4584459b14d9e5721d9bc6ed4eba319c437ef0145c157c946b70ecddcff6668682a002bcc + languageName: node + linkType: hard + "jiti@npm:^2.6.1": version: 2.6.1 resolution: "jiti@npm:2.6.1" @@ -8451,6 +8562,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.2, picomatch@npm:^4.0.7": + version: 4.0.7 + resolution: "picomatch@npm:4.0.7" + checksum: 10c0/beb6ae02c43ae44e84883b90830196d9046b1726ead292adcf7f57945e0bb0d992d68563d87e03b484b6f3c9a5c6defda7523477f047d7f0e663f126cc01787f + languageName: node + linkType: hard + "picomatch@npm:^4.0.4": version: 4.0.5 resolution: "picomatch@npm:4.0.5" @@ -8458,13 +8576,6 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.7": - version: 4.0.7 - resolution: "picomatch@npm:4.0.7" - checksum: 10c0/beb6ae02c43ae44e84883b90830196d9046b1726ead292adcf7f57945e0bb0d992d68563d87e03b484b6f3c9a5c6defda7523477f047d7f0e663f126cc01787f - languageName: node - linkType: hard - "pify@npm:^2.2.0": version: 2.3.0 resolution: "pify@npm:2.3.0" @@ -10294,6 +10405,13 @@ __metadata: languageName: node linkType: hard +"thumbhash@npm:^0.1.1": + version: 0.1.1 + resolution: "thumbhash@npm:0.1.1" + checksum: 10c0/9a23fe65a32b82400a966968a426f7e8a1bee352eedfc24b5342ebfe63f36e2cf8bba5b93adddf220bba625da2ad819ae5fbb6e9d5caa6f215e9ae274249bf87 + languageName: node + linkType: hard + "tinybench@npm:6.1.4": version: 6.1.4 resolution: "tinybench@npm:6.1.4" From 8be4cf7bf62f0dc8ac1e38ecd206bebd0d836df9 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 18:18:58 -0400 Subject: [PATCH 2/4] test(media): port responsive homepage coverage to Cypress Verify dimensions, AVIF/WebP candidates, responsive selection, and layout reservation before image responses at both viewports. Addresses PR #1355 comment 4054821928. --- .../e2e-playwright/homepage-media.spec.mjs | 213 ----------------- apps/docs/e2e/homepage/homepage-media.cy.js | 223 ++++++++++++++++++ 2 files changed, 223 insertions(+), 213 deletions(-) delete mode 100644 apps/docs/e2e-playwright/homepage-media.spec.mjs create mode 100644 apps/docs/e2e/homepage/homepage-media.cy.js diff --git a/apps/docs/e2e-playwright/homepage-media.spec.mjs b/apps/docs/e2e-playwright/homepage-media.spec.mjs deleted file mode 100644 index cb4b120a9..000000000 --- a/apps/docs/e2e-playwright/homepage-media.spec.mjs +++ /dev/null @@ -1,213 +0,0 @@ -import { expect, test } from "playwright/test"; -import { JSDOM } from "jsdom"; - -const responsiveNames = [ - "ReScript community photo 1", - "ReScript editor tooling", - "ReScript JavaScript output", -]; - -function candidates(sourceSet) { - return sourceSet.split(",").map((candidate) => { - const [url, width] = candidate.trim().split(/\s+/); - return { url, width: Number.parseInt(width, 10) }; - }); -} - -function imagePreloadUrls(document) { - return [ - ...document.querySelectorAll('link[rel="preload"][as="image"]'), - ].flatMap((link) => { - const sourceSet = link.getAttribute("imagesrcset"); - return [ - link.getAttribute("href"), - ...(sourceSet === null - ? [] - : candidates(sourceSet).map((entry) => entry.url)), - ]; - }); -} - -async function expectAvailableCandidates( - request, - { element, format, preloadedImages }, -) { - const entries = candidates(element?.getAttribute("srcset") ?? ""); - expect(entries.map((entry) => entry.width)).toEqual([360, 640, 1000]); - for (const entry of entries) { - expect(preloadedImages).not.toContain(entry.url); - const asset = await request.get(entry.url); - expect(asset.ok(), entry.url).toBe(true); - expect(asset.headers()["content-type"], entry.url).toContain( - `image/${format}`, - ); - expect((await asset.body()).byteLength, entry.url).toBeGreaterThan(0); - } -} - -async function decodeHomepageImages(page) { - for (const section of await page.locator("main section").all()) { - await section.scrollIntoViewIfNeeded(); - } - await page.locator("img").evaluateAll(async (images) => { - await Promise.all(images.map((image) => image.decode())); - await document.fonts.ready; - }); -} - -async function mediaBounds(page) { - await page.evaluate(() => document.fonts.ready); - return page.locator("main img, main video").evaluateAll((elements) => - elements.map((element) => { - const bounds = element.getBoundingClientRect(); - const hasBox = bounds.width !== 0 || bounds.height !== 0; - return { - width: bounds.width, - height: bounds.height, - top: hasBox ? bounds.top + window.scrollY : 0, - }; - }), - ); -} - -async function expectSelectedCandidate(image, expectedWidth, pageUrl) { - await image.scrollIntoViewIfNeeded(); - await image.evaluate((element) => element.decode()); - const selection = await image.evaluate((element) => ({ - currentSrc: element.currentSrc, - avif: element.parentElement.querySelector("source").srcset, - renderedWidth: element.getBoundingClientRect().width, - })); - const selected = candidates(selection.avif).find( - (entry) => new URL(entry.url, pageUrl).href === selection.currentSrc, - ); - expect(selected).toBeDefined(); - expect(selected?.width).toBe(expectedWidth); - expect(selected?.width).toBeGreaterThanOrEqual(selection.renderedWidth); -} - -test("prerendered homepage reserves dimensions for every image and video", async ({ - request, -}) => { - const response = await request.get("/"); - expect(response.ok()).toBe(true); - const { document } = new JSDOM(await response.text()).window; - const images = [...document.querySelectorAll("img")]; - const videos = [...document.querySelectorAll("video")]; - - expect(images).toHaveLength(63); - expect(videos).toHaveLength(3); - for (const element of [...images, ...videos]) { - const source = - element.getAttribute("src") ?? element.getAttribute("poster"); - expect(Number(element.getAttribute("width")), source).toBeGreaterThan(0); - expect(Number(element.getAttribute("height")), source).toBeGreaterThan(0); - } - for (const video of videos) { - expect(video.getAttribute("preload")).toBe("none"); - } -}); - -test("prerendered responsive media exposes valid AVIF and WebP candidates", async ({ - request, -}) => { - const response = await request.get("/"); - expect(response.ok()).toBe(true); - const { document } = new JSDOM(await response.text()).window; - const pictures = [...document.querySelectorAll("picture")]; - expect(pictures).toHaveLength(3); - const preloadedImages = imagePreloadUrls(document); - - for (const picture of pictures) { - const sources = [...picture.querySelectorAll("source")]; - expect(sources).toHaveLength(1); - const source = sources.at(0); - const image = picture.querySelector("img"); - expect(source?.getAttribute("type")).toBe("image/avif"); - expect(image?.getAttribute("loading")).toBe("lazy"); - expect(image?.getAttribute("decoding")).toBe("async"); - expect(image?.getAttribute("sizes")).toEqual(expect.any(String)); - expect(image?.getAttribute("sizes")).toBe(source?.getAttribute("sizes")); - expect(image?.getAttribute("sizes")).not.toBe(""); - - for (const [element, format] of [ - [source, "avif"], - [image, "webp"], - ]) { - await expectAvailableCandidates(request, { - element, - format, - preloadedImages, - }); - } - const fallback = await request.get(image?.getAttribute("src") ?? ""); - expect(fallback.ok()).toBe(true); - expect(fallback.headers()["content-type"]).toContain("image/webp"); - } -}); - -for (const width of [375, 1440]) { - test(`responsive images select appropriately sized files at ${width}px`, async ({ - page, - }) => { - await page.setViewportSize({ width, height: 900 }); - await page.goto("/"); - - for (const name of responsiveNames) { - const image = page.getByRole("img", { name, exact: true }); - const expectedWidth = - width === 375 && name !== "ReScript community photo 1" ? 360 : 640; - await expectSelectedCandidate(image, expectedWidth, page.url()); - } - - for (const index of [2, 3]) { - await page - .getByRole("button", { name: `Show community photo ${index}` }) - .click(); - const image = page.getByRole("img", { - name: `ReScript community photo ${index}`, - exact: true, - }); - await expectSelectedCandidate(image, 640, page.url()); - await expect(image).toBeVisible(); - await expect(image).toHaveAttribute( - "width", - index === 2 ? "1355" : "1000", - ); - await expect(image).toHaveAttribute( - "height", - index === 2 ? "904" : "667", - ); - } - }); - - test(`homepage media reserves its loaded layout before image responses at ${width}px`, async ({ - page, - }) => { - await page.setViewportSize({ width, height: 900 }); - const responses = Promise.withResolvers(); - await page.route("**/*", async (route) => { - if (route.request().resourceType() === "image") { - await responses.promise; - } - await route.continue(); - }); - try { - await page.goto("/", { waitUntil: "domcontentloaded" }); - const reserved = await mediaBounds(page); - responses.resolve(); - await decodeHomepageImages(page); - const loaded = await mediaBounds(page); - - expect(loaded).toHaveLength(reserved.length); - loaded.forEach((bounds, index) => { - expect(bounds.width).toBeCloseTo(reserved[index].width, 0); - expect(bounds.height).toBeCloseTo(reserved[index].height, 0); - expect(bounds.top).toBeCloseTo(reserved[index].top, 0); - }); - } finally { - responses.resolve(); - await page.unrouteAll({ behavior: "wait" }); - } - }); -} diff --git a/apps/docs/e2e/homepage/homepage-media.cy.js b/apps/docs/e2e/homepage/homepage-media.cy.js new file mode 100644 index 000000000..9f54baca4 --- /dev/null +++ b/apps/docs/e2e/homepage/homepage-media.cy.js @@ -0,0 +1,223 @@ +import { homepageDocument } from "./helpers.js"; + +const responsiveNames = [ + "ReScript community photo 1", + "ReScript editor tooling", + "ReScript JavaScript output", +]; + +function candidates(sourceSet) { + return sourceSet.split(",").map((candidate) => { + const [url, width] = candidate.trim().split(/\s+/); + return { url, width: Number.parseInt(width, 10) }; + }); +} + +function imagePreloadUrls(document) { + return [ + ...document.querySelectorAll('link[rel="preload"][as="image"]'), + ].flatMap((link) => { + const sourceSet = link.getAttribute("imagesrcset"); + return [ + link.getAttribute("href"), + ...(sourceSet === null + ? [] + : candidates(sourceSet).map((entry) => entry.url)), + ]; + }); +} + +function expectAvailableImage(url, format) { + return cy.request({ url, encoding: "binary" }).then((response) => { + expect(response.status, url).to.equal(200); + expect(response.headers["content-type"], url).to.include(`image/${format}`); + expect(response.body.length, url).to.be.greaterThan(0); + }); +} + +function expectAvailableCandidates(element, format, preloadedImages) { + const entries = candidates(element?.getAttribute("srcset") ?? ""); + expect(entries.map((entry) => entry.width)).to.deep.equal([360, 640, 1000]); + for (const entry of entries) { + expect(preloadedImages).not.to.include(entry.url); + expectAvailableImage(entry.url, format); + } +} + +function expectResponsivePicture(picture, preloadedImages) { + const sources = [...picture.querySelectorAll("source")]; + expect(sources).to.have.length(1); + const source = sources.at(0); + const image = picture.querySelector("img"); + expect(source?.getAttribute("type")).to.equal("image/avif"); + expect(image?.getAttribute("loading")).to.equal("lazy"); + expect(image?.getAttribute("decoding")).to.equal("async"); + expect(image?.getAttribute("sizes")).to.be.a("string").and.not.equal(""); + expect(image?.getAttribute("sizes")).to.equal(source?.getAttribute("sizes")); + expectAvailableCandidates(source, "avif", preloadedImages); + expectAvailableCandidates(image, "webp", preloadedImages); + expectAvailableImage(image?.getAttribute("src") ?? "", "webp"); +} + +function mediaBounds(window) { + return [...window.document.querySelectorAll("main img, main video")].map( + (element) => { + const bounds = element.getBoundingClientRect(); + const hasBox = bounds.width !== 0 || bounds.height !== 0; + return { + width: bounds.width, + height: bounds.height, + top: hasBox ? bounds.top + window.scrollY : 0, + }; + }, + ); +} + +function captureReservedLayout(window) { + const { document } = window; + document.body.getBoundingClientRect(); + // FontFaceSet.ready waits for load, which the held images would block. + const loadingFonts = [...document.fonts] + .filter((face) => face.status === "loading") + .map((face) => face.loaded); + return Promise.all(loadingFonts).then( + () => + new Promise((resolve) => { + window.requestAnimationFrame(() => resolve(mediaBounds(window))); + }), + ); +} + +function visitBeforeImageResponses() { + const snapshot = Promise.withResolvers(); + const release = snapshot.promise.then( + () => undefined, + () => undefined, + ); + cy.on("fail", (error) => { + snapshot.reject(error); + throw error; + }); + cy.intercept({ resourceType: "image" }, () => release); + cy.visit("/", { + onBeforeLoad(window) { + // Release images here so cy.visit can receive its required load event. + window.document.addEventListener( + "DOMContentLoaded", + () => { + captureReservedLayout(window).then(snapshot.resolve, snapshot.reject); + }, + { once: true }, + ); + }, + }); + return cy.then(() => snapshot.promise); +} + +function decodeHomepageImages() { + cy.get("main section").each((section) => cy.wrap(section).scrollIntoView()); + cy.get("img").each((image) => + cy + .wrap(image) + .scrollIntoView() + .should((images) => { + expect(images[0].complete, images[0].alt).to.equal(true); + expect(images[0].naturalWidth, images[0].alt).to.be.greaterThan(0); + }) + .then((images) => images[0].decode()), + ); + return cy.window().then((window) => window.document.fonts.ready); +} + +function expectSelectedCandidate(name, expectedWidth) { + return cy + .get(`img[alt="${name}"]`) + .scrollIntoView() + .then(async (images) => { + const image = images[0]; + await image.decode(); + const source = image.parentElement.querySelector("source"); + const selected = candidates(source.srcset).find( + (entry) => new URL(entry.url, image.baseURI).href === image.currentSrc, + ); + expect(selected, `${name} selected AVIF candidate`).not.to.equal( + undefined, + ); + expect(selected.width).to.equal(expectedWidth); + expect(selected.width).to.be.at.least( + image.getBoundingClientRect().width, + ); + }); +} + +it("prerendered homepage reserves dimensions for every image and video", () => { + homepageDocument().then((document) => { + const images = [...document.querySelectorAll("img")]; + const videos = [...document.querySelectorAll("video")]; + expect(images).to.have.length(63); + expect(videos).to.have.length(3); + for (const element of [...images, ...videos]) { + const source = + element.getAttribute("src") ?? element.getAttribute("poster"); + expect(Number(element.getAttribute("width")), source).to.be.greaterThan( + 0, + ); + expect(Number(element.getAttribute("height")), source).to.be.greaterThan( + 0, + ); + } + for (const video of videos) { + expect(video.getAttribute("preload")).to.equal("none"); + } + }); +}); + +it("prerendered responsive media exposes valid AVIF and WebP candidates", () => { + homepageDocument().then((document) => { + const pictures = [...document.querySelectorAll("picture")]; + expect(pictures).to.have.length(3); + const preloadedImages = imagePreloadUrls(document); + for (const picture of pictures) { + expectResponsivePicture(picture, preloadedImages); + } + }); +}); + +for (const width of [375, 1440]) { + it(`responsive images select appropriately sized files at ${width}px`, () => { + cy.viewport(width, 900); + cy.visit("/"); + for (const name of responsiveNames) { + const expectedWidth = + width === 375 && name !== "ReScript community photo 1" ? 360 : 640; + expectSelectedCandidate(name, expectedWidth); + } + for (const index of [2, 3]) { + cy.get(`button[aria-label="Show community photo ${index}"]`).click(); + expectSelectedCandidate(`ReScript community photo ${index}`, 640); + cy.get(`img[alt="ReScript community photo ${index}"]`) + .should("be.visible") + .and("have.attr", "width", index === 2 ? "1355" : "1000") + .and("have.attr", "height", index === 2 ? "904" : "667"); + } + }); + + it(`homepage media reserves its loaded layout before image responses at ${width}px`, () => { + cy.viewport(width, 900); + visitBeforeImageResponses().then((reserved) => { + decodeHomepageImages(); + cy.window().then((window) => { + const loaded = mediaBounds(window); + expect(loaded).to.have.length(reserved.length); + loaded.forEach((bounds, index) => { + for (const dimension of ["width", "height", "top"]) { + expect( + bounds[dimension], + `media ${index} ${dimension}`, + ).to.be.closeTo(reserved[index][dimension], 0.5); + } + }); + }); + }); + }); +} From 856ae7fa94bf3fe15efbc408690fc572996619e0 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sun, 20 Sep 2026 09:23:12 -0400 Subject: [PATCH 3/4] refactor(homepage): move video component to src Keep the responsive media component out of app/routes because it is not registered with React Router. --- apps/docs/{app/routes => src/components}/LandingPageVideo.res | 0 apps/docs/{app/routes => src/components}/LandingPageVideo.resi | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename apps/docs/{app/routes => src/components}/LandingPageVideo.res (100%) rename apps/docs/{app/routes => src/components}/LandingPageVideo.resi (100%) diff --git a/apps/docs/app/routes/LandingPageVideo.res b/apps/docs/src/components/LandingPageVideo.res similarity index 100% rename from apps/docs/app/routes/LandingPageVideo.res rename to apps/docs/src/components/LandingPageVideo.res diff --git a/apps/docs/app/routes/LandingPageVideo.resi b/apps/docs/src/components/LandingPageVideo.resi similarity index 100% rename from apps/docs/app/routes/LandingPageVideo.resi rename to apps/docs/src/components/LandingPageVideo.resi From 09c3b30af584e1cea538ba8672e74c5aab50e66e Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sun, 20 Sep 2026 10:20:31 -0400 Subject: [PATCH 4/4] test(homepage): port media Cypress spec to ReScript Preserve responsive image, media dimension, asset format, and reserved-layout coverage through typed browser bindings. --- apps/docs/e2e/bindings/Cypress.res | 42 ++- apps/docs/e2e/homepage/HomepageMedia.cy.res | 292 ++++++++++++++++++++ apps/docs/e2e/homepage/homepage-media.cy.js | 223 --------------- 3 files changed, 332 insertions(+), 225 deletions(-) create mode 100644 apps/docs/e2e/homepage/HomepageMedia.cy.res delete mode 100644 apps/docs/e2e/homepage/homepage-media.cy.js diff --git a/apps/docs/e2e/bindings/Cypress.res b/apps/docs/e2e/bindings/Cypress.res index ff6e045fb..793df1e4c 100644 --- a/apps/docs/e2e/bindings/Cypress.res +++ b/apps/docs/e2e/bindings/Cypress.res @@ -7,15 +7,24 @@ type consoleCall = {args: array} @send external getCalls: spy => array = "getCalls" @val external consoleArgumentString: option => string = "String" type console -type rec window = {document: Dom.document, console: console, navigator: {clipboard: clipboard}} +type rec window = { + document: Dom.document, + console: console, + navigator: {clipboard: clipboard}, + scrollY: float, +} and clipboard -type response = {status: int, body: string} +type response = {status: int, body: string, headers: Dict.t} type automation = {command: string, params?: {permissions: array, origin: string}} type request = {url: string, resourceType: string} type routeMatcher = {resourceType?: string, pathname?: string} type searchRouteMatcher = {hostname: RegExp.t, pathname: RegExp.t} type staticResponse = {statusCode: int, body: JSON.t} type wrapOptions = {log: bool} +type requestOptions = {url: string, encoding: string} +type visitOptions = {onBeforeLoad: window => unit} +type listenerOptions = {once: bool} +type bounds = {width: float, height: float, top: float} type url type elementList type fontFaceSet @@ -30,6 +39,7 @@ type cssStyle @val external beforeEach: (unit => unit) => unit = "beforeEach" @val external afterEach: (unit => unit) => unit = "afterEach" @val @scope("cy") external visit: string => unit = "visit" +@val @scope("cy") external visitWithOptions: (string, visitOptions) => unit = "visit" @val @scope("cy") external viewport: (int, int) => unit = "viewport" @val @scope("cy") external get: string => chain = "get" @val @scope("cy") external alias: string => chain<'a> = "get" @@ -40,6 +50,7 @@ type cssStyle @val @scope("cy") external cyWindow: unit => chain = "window" @val @scope("cy") external cyDocument: unit => chain = "document" @val @scope("cy") external request: string => chain = "request" +@val @scope("cy") external requestWithOptions: requestOptions => chain = "request" @val @scope("cy") external intercept: (routeMatcher, request => unit) => chain = "intercept" @val @scope("cy") external interceptAll: (string, request => unit) => chain = "intercept" @val @scope("cy") external interceptRoute: routeMatcher => chain = "intercept" @@ -49,10 +60,14 @@ external interceptStatic: (searchRouteMatcher, staticResponse) => chain = external interceptPattern: (RegExp.t, request => unit) => chain = "intercept" @val @scope("cy") external interceptDeferred: (RegExp.t, unit => promise) => chain = "intercept" +@val @scope("cy") +external interceptDeferredRequest: (routeMatcher, request => promise) => chain = + "intercept" @val @scope("cy") external wait: string => chain = "wait" @val @scope("cy") external wrap: 'a => chain<'a> = "wrap" @val @scope("cy") external wrapWithOptions: ('a, wrapOptions) => chain<'a> = "wrap" @val @scope("cy") external run: (unit => promise) => chain = "then" +@val @scope("cy") external runPromise: (unit => promise<'a>) => chain<'a> = "then" @val @scope("cy") external do_: (unit => unit) => chain = "then" @val @scope("cy") external realPressKey: string => chain = "realPress" @val @scope("cy") external realPressKeys: array => chain = "realPress" @@ -120,6 +135,9 @@ external containsChildRegex: (chain, string, RegExp.t) => chain unit = "equal" @send @scope(("to", "include")) external includeMembers: (assertion, array<'a>) => unit = "members" @send @scope(("to", "be")) external greaterThan: (assertion, int) => unit = "greaterThan" +@send @scope(("to", "be")) external greaterThanFloat: (assertion, float) => unit = "greaterThan" +@send @scope(("to", "be", "at")) external atLeast: (assertion, float) => unit = "least" +@send @scope(("to", "be")) external closeTo: (assertion, float, float) => unit = "closeTo" @send @scope("to") external match_: (assertion, RegExp.t) => unit = "match" type parser @@ -127,6 +145,13 @@ type parser @send external parseHtml: (parser, string, @as("text/html") _) => Dom.document = "parseFromString" @send external querySelector: (Dom.document, string) => Null.t = "querySelector" @send external querySelectorAll: (Dom.document, string) => elementList = "querySelectorAll" +@send +external querySelectorFromElement: (Dom.element, string) => Null.t = "querySelector" +@send +external querySelectorAllFromElement: (Dom.element, string) => elementList = "querySelectorAll" +@send +external addEventListenerOnce: (Dom.document, string, unit => unit, listenerOptions) => unit = + "addEventListener" @get external body: Dom.document => Dom.element = "body" @get external documentElement: Dom.document => Dom.element = "documentElement" @get external currentScript: Dom.document => Null.t = "currentScript" @@ -134,17 +159,30 @@ type parser @get external innerHTML: Dom.element => string = "innerHTML" @get external outerHTML: Dom.element => string = "outerHTML" @send external getAttribute: (Dom.element, string) => Null.t = "getAttribute" +@send external boundingRect: Dom.element => bounds = "getBoundingClientRect" +@get external alt: Dom.element => string = "alt" +@get external baseURI: Dom.element => string = "baseURI" +@get external currentSrc: Dom.element => string = "currentSrc" +@get external parentElement: Dom.element => Nullable.t = "parentElement" +@send external decode: Dom.element => promise = "decode" @val @scope("Array") external elementsFrom: elementList => array = "from" @new external url: string => url = "URL" +@new external urlWithBase: (string, string) => url = "URL" @get external pathname: url => string = "pathname" @get external hostname: url => string = "hostname" @get external urlOrigin: url => string = "origin" +@get external href: url => string = "href" @get external fonts: Dom.document => fontFaceSet = "fonts" @get external fontsReady: fontFaceSet => promise = "ready" @send external loadFont: (fontFaceSet, string) => promise> = "load" @send external checkFont: (fontFaceSet, string) => bool = "check" +@val @scope("Array") external fontFacesFrom: fontFaceSet => array = "from" +@get external fontStatus: fontFace => string = "status" +@get external fontLoaded: fontFace => promise = "loaded" + +@send external windowRequestAnimationFrame: (window, float => unit) => int = "requestAnimationFrame" @get external styleSheets: Dom.document => styleSheetList = "styleSheets" @val @scope("Array") external styleSheetsFrom: styleSheetList => array = "from" diff --git a/apps/docs/e2e/homepage/HomepageMedia.cy.res b/apps/docs/e2e/homepage/HomepageMedia.cy.res new file mode 100644 index 000000000..da99a441a --- /dev/null +++ b/apps/docs/e2e/homepage/HomepageMedia.cy.res @@ -0,0 +1,292 @@ +open Cypress +open HomepageHelpers + +type candidate = {url: string, width: int} + +let responsiveNames = [ + "ReScript community photo 1", + "ReScript editor tooling", + "ReScript JavaScript output", +] + +let candidates = sourceSet => + sourceSet + ->String.split(",") + ->Array.map(candidate => { + let parts = candidate->String.trim->String.split(" ")->Array.filter(part => part !== "") + { + url: parts->Array.get(0)->Option.getOrThrow, + width: parts + ->Array.get(1) + ->Option.flatMap(part => part->String.replace("w", "")->Int.fromString) + ->Option.getOrThrow, + } + }) + +let imagePreloadUrls = document => + document + ->querySelectorAll(`link[rel="preload"][as="image"]`) + ->elementsFrom + ->Array.flatMap(link => { + let sourceUrls = + link + ->getAttribute("imagesrcset") + ->Null.toOption + ->Option.map(sourceSet => sourceSet->candidates->Array.map(entry => entry.url)) + ->Option.getOr([]) + link + ->getAttribute("href") + ->Null.toOption + ->Option.map(href => [href, ...sourceUrls]) + ->Option.getOr(sourceUrls) + }) + +let expectAvailableImage = (url, format) => + requestWithOptions({url, encoding: "binary"})->then(response => { + expect(response.status, ~message=url)->equal(200) + expect(response.headers->Dict.get("content-type")->Option.getOr(""), ~message=url)->include_( + `image/${format}`, + ) + expect(response.body->String.length, ~message=url)->greaterThan(0) + }) + +let requireElement = (element, message) => { + expect(element->Option.isSome, ~message)->equal(true) + element->Option.getOrThrow +} + +let expectAvailableCandidates = (element, format, preloadedImages) => { + let entries = element->getAttribute("srcset")->Null.toOption->Option.getOr("")->candidates + expect(entries->Array.map(entry => entry.width))->deepEqual([360, 640, 1000]) + entries->Array.forEach(entry => { + expect(preloadedImages->Array.includes(entry.url))->equal(false) + expectAvailableImage(entry.url, format)->ignore + }) +} + +let expectResponsivePicture = (picture, preloadedImages) => { + let sources = picture->querySelectorAllFromElement("source")->elementsFrom + expect(sources->Array.length)->equal(1) + let source = sources->Array.get(0)->requireElement("responsive picture source") + let image = + picture + ->querySelectorFromElement("img") + ->Null.toOption + ->requireElement("responsive picture image") + expect(source->getAttribute("type")->Null.toOption)->equal(Some("image/avif")) + expect(image->getAttribute("loading")->Null.toOption)->equal(Some("lazy")) + expect(image->getAttribute("decoding")->Null.toOption)->equal(Some("async")) + let sizes = image->getAttribute("sizes")->Null.toOption->Option.getOrThrow + expect(sizes === "")->equal(false) + expect(source->getAttribute("sizes")->Null.toOption)->equal(Some(sizes)) + expectAvailableCandidates(source, "avif", preloadedImages) + expectAvailableCandidates(image, "webp", preloadedImages) + expectAvailableImage(image->getAttribute("src")->Null.toOption->Option.getOr(""), "webp")->ignore +} + +let mediaBounds = window => + window.document + ->querySelectorAll("main img, main video") + ->elementsFrom + ->Array.map(element => { + let bounds = element->boundingRect + let hasBox = bounds.width !== 0.0 || bounds.height !== 0.0 + { + width: bounds.width, + height: bounds.height, + top: hasBox ? bounds.top +. window.scrollY : 0.0, + } + }) + +let captureReservedLayout = async window => { + window.document->body->boundingRect->ignore + let loadingFonts = + window.document + ->fonts + ->fontFacesFrom + ->Array.filter(font => font->fontStatus === "loading") + ->Array.map(font => font->fontLoaded) + let _ = await loadingFonts->Promise.all + await Promise.make((resolve, _) => + window->windowRequestAnimationFrame(_ => resolve(window->mediaBounds))->ignore + ) +} + +let visitBeforeImageResponses = () => { + let snapshot = Promise.withResolvers() + let release = snapshot.promise->Promise.then(_ => Promise.resolve()) + interceptDeferredRequest({resourceType: "image"}, _ => release)->ignore + visitWithOptions( + "/", + { + onBeforeLoad: window => + window.document->addEventListenerOnce( + "DOMContentLoaded", + () => { + let capture = async () => { + let reserved = await window->captureReservedLayout + snapshot.resolve(reserved) + } + capture()->ignore + }, + {once: true}, + ), + }, + ) + runPromise(() => snapshot.promise) +} + +let decodeHomepageImages = () => { + get("main section")->each(section => wrap(section)->scrollIntoView->ignore)->ignore + get("img") + ->each(image => + wrap(image) + ->scrollIntoView + ->shouldSatisfy(images => { + let image = images->item(0)->requireElement("homepage image") + expect(image->complete, ~message=image->alt)->equal(true) + expect(image->naturalWidth, ~message=image->alt)->greaterThan(0) + }) + ->thenPromise(images => images->item(0)->requireElement("homepage image")->decode) + ->ignore + ) + ->ignore + cyWindow()->thenPromise(window => window.document->fonts->fontsReady)->ignore +} + +let expectSelectedCandidate = (name, expectedWidth) => + get(`img[alt="${name}"]`) + ->scrollIntoView + ->thenPromise(async images => { + let image = images->item(0)->requireElement(name) + await image->decode + let source = + image + ->parentElement + ->Nullable.toOption + ->Option.flatMap(parent => parent->querySelectorFromElement("source")->Null.toOption) + ->requireElement(`${name} source`) + let selected = + source + ->getAttribute("srcset") + ->Null.toOption + ->Option.getOr("") + ->candidates + ->Array.find(entry => entry.url->urlWithBase(image->baseURI)->href === image->currentSrc) + ->requireElement(`${name} selected AVIF candidate`) + expect(selected.width)->equal(expectedWidth) + let imageBounds = image->boundingRect + expect(selected.width->Float.fromInt)->atLeast(imageBounds.width) + }) + +it("prerendered homepage reserves dimensions for every image and video", () => { + homepageDocument(document => { + let images = document->querySelectorAll("img")->elementsFrom + let videos = document->querySelectorAll("video")->elementsFrom + expect(images->Array.length)->equal(63) + expect(videos->Array.length)->equal(3) + images + ->Array.concat(videos) + ->Array.forEach( + element => { + let source = + element + ->getAttribute("src") + ->Null.toOption + ->Option.orElse(element->getAttribute("poster")->Null.toOption) + ->Option.getOr("") + expect( + element + ->getAttribute("width") + ->Null.toOption + ->Option.flatMap(Float.fromString) + ->Option.getOr(0.0), + ~message=source, + )->greaterThanFloat(0.0) + expect( + element + ->getAttribute("height") + ->Null.toOption + ->Option.flatMap(Float.fromString) + ->Option.getOr(0.0), + ~message=source, + )->greaterThanFloat(0.0) + }, + ) + videos->Array.forEach( + video => expect(video->getAttribute("preload")->Null.toOption)->equal(Some("none")), + ) + }) +}) + +it("prerendered responsive media exposes valid AVIF and WebP candidates", () => { + homepageDocument(document => { + let pictures = document->querySelectorAll("picture")->elementsFrom + expect(pictures->Array.length)->equal(3) + let preloadedImages = document->imagePreloadUrls + pictures->Array.forEach(picture => expectResponsivePicture(picture, preloadedImages)) + }) +}) + +[375, 1440]->Array.forEach(width => { + it(`responsive images select appropriately sized files at ${width->Int.toString}px`, () => { + viewport(width, 900) + visit("/") + responsiveNames->Array.forEach( + name => { + let expectedWidth = width === 375 && name !== "ReScript community photo 1" ? 360 : 640 + expectSelectedCandidate(name, expectedWidth)->ignore + }, + ) + [2, 3]->Array.forEach( + index => { + get(`button[aria-label="Show community photo ${index->Int.toString}"]`)->click->ignore + expectSelectedCandidate(`ReScript community photo ${index->Int.toString}`, 640)->ignore + get(`img[alt="ReScript community photo ${index->Int.toString}"]`) + ->should("be.visible") + ->shouldAttribute("width", index === 2 ? "1355" : "1000") + ->shouldAttribute("height", index === 2 ? "904" : "667") + ->ignore + }, + ) + }) + + it( + `homepage media reserves its loaded layout before image responses at ${width->Int.toString}px`, + () => { + viewport(width, 900) + visitBeforeImageResponses() + ->then( + reserved => { + decodeHomepageImages() + cyWindow() + ->then( + window => { + let loaded = window->mediaBounds + expect(loaded->Array.length)->equal(reserved->Array.length) + loaded->Array.forEachWithIndex( + (bounds, index) => { + let expected = reserved->Array.get(index)->Option.getOrThrow + expect(bounds.width, ~message=`media ${index->Int.toString} width`)->closeTo( + expected.width, + 0.5, + ) + expect(bounds.height, ~message=`media ${index->Int.toString} height`)->closeTo( + expected.height, + 0.5, + ) + expect(bounds.top, ~message=`media ${index->Int.toString} top`)->closeTo( + expected.top, + 0.5, + ) + }, + ) + }, + ) + ->ignore + }, + ) + ->ignore + }, + ) +}) diff --git a/apps/docs/e2e/homepage/homepage-media.cy.js b/apps/docs/e2e/homepage/homepage-media.cy.js deleted file mode 100644 index 9f54baca4..000000000 --- a/apps/docs/e2e/homepage/homepage-media.cy.js +++ /dev/null @@ -1,223 +0,0 @@ -import { homepageDocument } from "./helpers.js"; - -const responsiveNames = [ - "ReScript community photo 1", - "ReScript editor tooling", - "ReScript JavaScript output", -]; - -function candidates(sourceSet) { - return sourceSet.split(",").map((candidate) => { - const [url, width] = candidate.trim().split(/\s+/); - return { url, width: Number.parseInt(width, 10) }; - }); -} - -function imagePreloadUrls(document) { - return [ - ...document.querySelectorAll('link[rel="preload"][as="image"]'), - ].flatMap((link) => { - const sourceSet = link.getAttribute("imagesrcset"); - return [ - link.getAttribute("href"), - ...(sourceSet === null - ? [] - : candidates(sourceSet).map((entry) => entry.url)), - ]; - }); -} - -function expectAvailableImage(url, format) { - return cy.request({ url, encoding: "binary" }).then((response) => { - expect(response.status, url).to.equal(200); - expect(response.headers["content-type"], url).to.include(`image/${format}`); - expect(response.body.length, url).to.be.greaterThan(0); - }); -} - -function expectAvailableCandidates(element, format, preloadedImages) { - const entries = candidates(element?.getAttribute("srcset") ?? ""); - expect(entries.map((entry) => entry.width)).to.deep.equal([360, 640, 1000]); - for (const entry of entries) { - expect(preloadedImages).not.to.include(entry.url); - expectAvailableImage(entry.url, format); - } -} - -function expectResponsivePicture(picture, preloadedImages) { - const sources = [...picture.querySelectorAll("source")]; - expect(sources).to.have.length(1); - const source = sources.at(0); - const image = picture.querySelector("img"); - expect(source?.getAttribute("type")).to.equal("image/avif"); - expect(image?.getAttribute("loading")).to.equal("lazy"); - expect(image?.getAttribute("decoding")).to.equal("async"); - expect(image?.getAttribute("sizes")).to.be.a("string").and.not.equal(""); - expect(image?.getAttribute("sizes")).to.equal(source?.getAttribute("sizes")); - expectAvailableCandidates(source, "avif", preloadedImages); - expectAvailableCandidates(image, "webp", preloadedImages); - expectAvailableImage(image?.getAttribute("src") ?? "", "webp"); -} - -function mediaBounds(window) { - return [...window.document.querySelectorAll("main img, main video")].map( - (element) => { - const bounds = element.getBoundingClientRect(); - const hasBox = bounds.width !== 0 || bounds.height !== 0; - return { - width: bounds.width, - height: bounds.height, - top: hasBox ? bounds.top + window.scrollY : 0, - }; - }, - ); -} - -function captureReservedLayout(window) { - const { document } = window; - document.body.getBoundingClientRect(); - // FontFaceSet.ready waits for load, which the held images would block. - const loadingFonts = [...document.fonts] - .filter((face) => face.status === "loading") - .map((face) => face.loaded); - return Promise.all(loadingFonts).then( - () => - new Promise((resolve) => { - window.requestAnimationFrame(() => resolve(mediaBounds(window))); - }), - ); -} - -function visitBeforeImageResponses() { - const snapshot = Promise.withResolvers(); - const release = snapshot.promise.then( - () => undefined, - () => undefined, - ); - cy.on("fail", (error) => { - snapshot.reject(error); - throw error; - }); - cy.intercept({ resourceType: "image" }, () => release); - cy.visit("/", { - onBeforeLoad(window) { - // Release images here so cy.visit can receive its required load event. - window.document.addEventListener( - "DOMContentLoaded", - () => { - captureReservedLayout(window).then(snapshot.resolve, snapshot.reject); - }, - { once: true }, - ); - }, - }); - return cy.then(() => snapshot.promise); -} - -function decodeHomepageImages() { - cy.get("main section").each((section) => cy.wrap(section).scrollIntoView()); - cy.get("img").each((image) => - cy - .wrap(image) - .scrollIntoView() - .should((images) => { - expect(images[0].complete, images[0].alt).to.equal(true); - expect(images[0].naturalWidth, images[0].alt).to.be.greaterThan(0); - }) - .then((images) => images[0].decode()), - ); - return cy.window().then((window) => window.document.fonts.ready); -} - -function expectSelectedCandidate(name, expectedWidth) { - return cy - .get(`img[alt="${name}"]`) - .scrollIntoView() - .then(async (images) => { - const image = images[0]; - await image.decode(); - const source = image.parentElement.querySelector("source"); - const selected = candidates(source.srcset).find( - (entry) => new URL(entry.url, image.baseURI).href === image.currentSrc, - ); - expect(selected, `${name} selected AVIF candidate`).not.to.equal( - undefined, - ); - expect(selected.width).to.equal(expectedWidth); - expect(selected.width).to.be.at.least( - image.getBoundingClientRect().width, - ); - }); -} - -it("prerendered homepage reserves dimensions for every image and video", () => { - homepageDocument().then((document) => { - const images = [...document.querySelectorAll("img")]; - const videos = [...document.querySelectorAll("video")]; - expect(images).to.have.length(63); - expect(videos).to.have.length(3); - for (const element of [...images, ...videos]) { - const source = - element.getAttribute("src") ?? element.getAttribute("poster"); - expect(Number(element.getAttribute("width")), source).to.be.greaterThan( - 0, - ); - expect(Number(element.getAttribute("height")), source).to.be.greaterThan( - 0, - ); - } - for (const video of videos) { - expect(video.getAttribute("preload")).to.equal("none"); - } - }); -}); - -it("prerendered responsive media exposes valid AVIF and WebP candidates", () => { - homepageDocument().then((document) => { - const pictures = [...document.querySelectorAll("picture")]; - expect(pictures).to.have.length(3); - const preloadedImages = imagePreloadUrls(document); - for (const picture of pictures) { - expectResponsivePicture(picture, preloadedImages); - } - }); -}); - -for (const width of [375, 1440]) { - it(`responsive images select appropriately sized files at ${width}px`, () => { - cy.viewport(width, 900); - cy.visit("/"); - for (const name of responsiveNames) { - const expectedWidth = - width === 375 && name !== "ReScript community photo 1" ? 360 : 640; - expectSelectedCandidate(name, expectedWidth); - } - for (const index of [2, 3]) { - cy.get(`button[aria-label="Show community photo ${index}"]`).click(); - expectSelectedCandidate(`ReScript community photo ${index}`, 640); - cy.get(`img[alt="ReScript community photo ${index}"]`) - .should("be.visible") - .and("have.attr", "width", index === 2 ? "1355" : "1000") - .and("have.attr", "height", index === 2 ? "904" : "667"); - } - }); - - it(`homepage media reserves its loaded layout before image responses at ${width}px`, () => { - cy.viewport(width, 900); - visitBeforeImageResponses().then((reserved) => { - decodeHomepageImages(); - cy.window().then((window) => { - const loaded = mediaBounds(window); - expect(loaded).to.have.length(reserved.length); - loaded.forEach((bounds, index) => { - for (const dimension of ["width", "height", "top"]) { - expect( - bounds[dimension], - `media ${index} ${dimension}`, - ).to.be.closeTo(reserved[index][dimension], 0.5); - } - }); - }); - }); - }); -}