diff --git a/astro/markdoc.config.mjs b/astro/markdoc.config.mjs index 1482994b139..b6068f76955 100644 --- a/astro/markdoc.config.mjs +++ b/astro/markdoc.config.mjs @@ -38,6 +38,10 @@ export default defineMarkdocConfig({ render: component("./src/components/Alert/Alert.astro"), ...schema.tags.alert, }, + img: { + render: component("./src/components/Img/Img.astro"), + ...schema.tags.img, + }, tabs: { render: component("./src/components/Tabs/Tabs.astro"), ...schema.tags.tabs, diff --git a/astro/markdoc.schema.mjs b/astro/markdoc.schema.mjs index 89985ca7b86..c1057e9fd99 100644 --- a/astro/markdoc.schema.mjs +++ b/astro/markdoc.schema.mjs @@ -21,6 +21,33 @@ export default { }, }, }, + img: { + attributes: { + src: { type: String, required: true }, + alt: { type: String }, + caption: { type: String }, + width: { type: String, default: false }, + height: { type: String, default: false }, + widthPercent: { type: Number }, + video: { type: Boolean, default: false }, + inline: { type: Boolean, default: false }, + popup: { type: Boolean, default: true }, + }, + validate(node) { + const { width, height, widthPercent } = node.attributes; + if (widthPercent !== undefined && (width !== undefined || height !== undefined)) { + return [ + { + id: "img-width-percent-conflict", + level: "error", + message: + "img: widthPercent can't be combined with width or height. widthPercent overrides them silently in the rendered CSS, so use one sizing approach.", + }, + ]; + } + return []; + }, + }, tabs: { attributes: {}, }, diff --git a/astro/src/components/Img/Img.astro b/astro/src/components/Img/Img.astro new file mode 100644 index 00000000000..dfc5645d40b --- /dev/null +++ b/astro/src/components/Img/Img.astro @@ -0,0 +1,59 @@ +--- +import ImgController from "./ImgController"; +import ImgVideo from "./ImgVideo"; +import { IMAGES_URL } from "@config/images"; + +interface Props { + src: string; + alt?: string; + caption?: string; + width?: string; + height?: string; + widthPercent?: number; + video?: boolean; + inline?: boolean; + popup?: boolean; +} + +const { + src, + alt, + caption, + width, + height, + widthPercent, + video = false, + inline = false, + popup = true, +} = Astro.props; + +const imageUrl = `${IMAGES_URL}/images/${src}`; + +const srcset = `${imageUrl}?auto=format&fit=max&w=850 1x, ${imageUrl}?auto=format&fit=max&w=850&dpr=2 2x`; +const popupHref = `${imageUrl}?fit=max&auto=format`; +--- + +{ + video ? ( + + ) : ( + + ) +} diff --git a/astro/src/components/Img/ImgController.module.css b/astro/src/components/Img/ImgController.module.css new file mode 100644 index 00000000000..3ab9123ad89 --- /dev/null +++ b/astro/src/components/Img/ImgController.module.css @@ -0,0 +1,81 @@ +.img__figure { + margin: 0 0 1rem; +} + +.img__image { + max-width: 100%; + height: auto; + /* matches Hugo's $ddgray (#d6d6d6); no equivalent design token exists yet */ + border: 1px solid #d6d6d6; +} + +.img__link--popup { + cursor: zoom-in; +} + +/* Ported from hugo/layouts/partials/global-modals/global-modals.html + + hugo/assets/scripts/components/global-modals.js (Bootstrap 5 Modal). */ + +.img-lightbox__overlay { + position: fixed; + inset: 0; + background: var(--hugo-modal-overlay); + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; +} + +.img-lightbox__overlay[hidden] { + display: none; +} + +.img-lightbox__dialog { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-width: 90vw; + max-height: 90vh; +} + +.img-lightbox__spinner, +.img-lightbox__spinner::after { + border-radius: 50%; + width: 5em; + height: 5em; +} + +.img-lightbox__spinner { + font-size: 10px; + position: relative; + text-indent: -9999em; + border-top: 0.55em solid rgba(234, 234, 234, 0.5); + border-right: 0.55em solid rgba(234, 234, 234, 0.5); + border-bottom: 0.55em solid rgba(234, 234, 234, 0.5); + border-left: 0.55em solid #ffffff; + transform: translateZ(0); + animation: img-lightbox-spin 1.1s infinite linear; +} + +@keyframes img-lightbox-spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.img-lightbox__image { + max-width: 90vw; + max-height: 90vh; + object-fit: contain; +} + +.img-lightbox__caption { + color: #ffffff; + text-align: center; + margin: 0.5rem 0 0; +} diff --git a/astro/src/components/Img/ImgController.tsx b/astro/src/components/Img/ImgController.tsx new file mode 100644 index 00000000000..a7da0a29ef0 --- /dev/null +++ b/astro/src/components/Img/ImgController.tsx @@ -0,0 +1,286 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import type { JSX } from "preact"; +import styles from "./ImgController.module.css"; +import { classListFactory } from "@lib/cssUtils/classListFactory"; + +const cl = classListFactory(styles); + +interface ImgControllerProps { + srcset: string; + popupHref: string; + alt?: string; + caption?: string; + width?: string; + height?: string; + widthPercent?: number; + inline?: boolean; + popup?: boolean; +} + +interface DisplayedImage { + src: string; + alt?: string; + caption?: string; +} + +/** + * Resizes an element/container pair to a max-90vw/90vh box, preserving the + * image's aspect ratio. Ported from Hugo's global-modals.js `resize()` so + * the lightbox scales images identically to the Bootstrap modal it replaces. + */ +function applyLightboxResize( + imageElement: HTMLImageElement, + dialogElement: HTMLDivElement, + naturalWidth: number, + naturalHeight: number, +) { + if (!naturalWidth || !naturalHeight) return; + + const parentWidth = (window.innerWidth / 100) * 90; + const parentHeight = (window.innerHeight / 100) * 90; + + imageElement.style.width = ""; + imageElement.style.height = ""; + dialogElement.style.width = ""; + dialogElement.style.height = ""; + + let ratio = Math.max( + naturalWidth / (parentWidth - 1), + naturalHeight / (parentHeight - 1), + ); + + let width: number; + let height: number; + if (ratio > 1) { + ratio = naturalHeight / Math.floor(naturalHeight / ratio); + width = naturalWidth / ratio; + height = naturalHeight / ratio; + } else { + width = naturalWidth; + height = naturalHeight; + } + + imageElement.style.width = `${width}px`; + imageElement.style.height = `${height}px`; + dialogElement.style.width = `${width}px`; + dialogElement.style.height = `${height}px`; +} + +interface SizingProps { + width?: string; + height?: string; + widthPercent?: number; +} + +function PictureImage({ + srcset, + alt, + width, + height, + widthPercent, +}: SizingProps & { srcset: string; alt?: string }) { + return ( + + + + ); +} + +interface FigureWithLightboxProps extends SizingProps { + srcset: string; + alt?: string; + caption?: string; + popup: boolean; + popupHref: string; + onTriggerClick: (e: MouseEvent) => void; +} + +function FigureWithLightbox({ + srcset, + alt, + caption, + popup, + popupHref, + width, + height, + widthPercent, + onTriggerClick, +}: FigureWithLightboxProps) { + const image = ( + + ); + + return ( + + {popup ? ( + + {image} + + ) : ( + image + )} + {caption && {caption}} + + ); +} + +export default function ImgController({ + srcset, + popupHref, + alt, + caption, + width, + height, + widthPercent, + inline = false, + popup = true, +}: ImgControllerProps) { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [displayed, setDisplayed] = useState(null); + const overlayRef = useRef(null); + const dialogRef = useRef(null); + const lightboxImageRef = useRef(null); + + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("keydown", handleKey); + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", handleKey); + document.body.style.overflow = ""; + }; + }, [open]); + + useEffect(() => { + if (!open) { + setDisplayed(null); + setLoading(false); + return; + } + function handleResize() { + const imageElement = lightboxImageRef.current; + const dialogElement = dialogRef.current; + if (!imageElement || !dialogElement) return; + applyLightboxResize( + imageElement, + dialogElement, + imageElement.naturalWidth, + imageElement.naturalHeight, + ); + } + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [open]); + + function handleTriggerClick(e: MouseEvent) { + e.preventDefault(); + setDisplayed({ src: popupHref, alt, caption }); + setLoading(true); + setOpen(true); + } + + function handleOverlayClick(e: MouseEvent) { + if (e.target === overlayRef.current) setOpen(false); + } + + function handleLightboxImageLoad() { + const imageElement = lightboxImageRef.current; + const dialogElement = dialogRef.current; + if (imageElement && dialogElement) { + applyLightboxResize( + imageElement, + dialogElement, + imageElement.naturalWidth, + imageElement.naturalHeight, + ); + } + setLoading(false); + } + + let media: JSX.Element; + const showsLightbox = !inline && popup; + if (inline) { + media = ( + + ); + } else { + media = ( + + ); + } + + if (!showsLightbox) { + return media; + } + + return ( + <> + {media} + + + {open && displayed && ( + <> + {loading && } + + {displayed.caption && ( + {displayed.caption} + )} + > + )} + + + > + ); +} diff --git a/astro/src/components/Img/ImgVideo.module.css b/astro/src/components/Img/ImgVideo.module.css new file mode 100644 index 00000000000..6a7e4806d2e --- /dev/null +++ b/astro/src/components/Img/ImgVideo.module.css @@ -0,0 +1,10 @@ +.img__figure { + margin: 0 0 1rem; +} + +.img__video { + max-width: 100%; + height: auto; + /* matches Hugo's $ddgray (#d6d6d6); no equivalent design token exists yet */ + border: 1px solid #d6d6d6; +} diff --git a/astro/src/components/Img/ImgVideo.tsx b/astro/src/components/Img/ImgVideo.tsx new file mode 100644 index 00000000000..3f4dcd1f2aa --- /dev/null +++ b/astro/src/components/Img/ImgVideo.tsx @@ -0,0 +1,36 @@ +import styles from "./ImgVideo.module.css"; +import { classListFactory } from "@lib/cssUtils/classListFactory"; + +const cl = classListFactory(styles); + +interface ImgVideoProps { + imageUrl: string; + width?: string; + height?: string; + widthPercent?: number; +} + +export default function ImgVideo({ + imageUrl, + width, + height, + widthPercent, +}: ImgVideoProps) { + return ( + + + + + + ); +} diff --git a/astro/src/components/Img/plaintext/Img.ts b/astro/src/components/Img/plaintext/Img.ts new file mode 100644 index 00000000000..cd286f08944 --- /dev/null +++ b/astro/src/components/Img/plaintext/Img.ts @@ -0,0 +1,35 @@ +/** + * AST twin of the `{% img %}` component (`Img.astro`). + * + * Mirrors the authored `{% img %}` tag shape rather than a plain Markdown + * image, since `src` here is the resolved full-size CDN URL, not the + * content-relative path authors write. Layout-only attributes (`width`, + * `height`, `widthPercent`, `popup`) are dropped: none of them affect a + * plaintext consumer. + */ + +import type { Node as MarkdocNode } from "@markdoc/markdoc"; +import { tag } from "@lib/plaintext/helpers"; + +export interface ImgNodeInput { + src: string; + alt?: string; + caption?: string; + video?: boolean; + inline?: boolean; +} + +export function imgNode({ + src, + alt, + caption, + video, + inline, +}: ImgNodeInput): MarkdocNode { + const attributes: Record = { src }; + if (alt) attributes.alt = alt; + if (video) attributes.video = true; + if (caption) attributes.caption = caption; + if (inline) attributes.inline = true; + return tag("img", attributes); +} diff --git a/astro/src/components/Img/plaintext/tests/unit.test.ts b/astro/src/components/Img/plaintext/tests/unit.test.ts new file mode 100644 index 00000000000..e8c666812b3 --- /dev/null +++ b/astro/src/components/Img/plaintext/tests/unit.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { format } from "@markdoc/markdoc"; +import { imgNode } from "../Img"; + +describe("imgNode", () => { + it("renders an img tag with src and alt", () => { + const node = imgNode({ + src: "https://prod.img.url", + alt: "Browse the integration pipeline library", + }); + const result = format(node); + + expect(result).toContain("{% img"); + expect(result).toContain('src="https://prod.img.url"'); + expect(result).toContain('alt="Browse the integration pipeline library"'); + expect(result).toContain("/%}"); + }); + + it("omits the alt attribute when alt is not set", () => { + const node = imgNode({ src: "https://prod.img.url" }); + const result = format(node); + + expect(result).not.toContain("alt="); + }); + + it("includes caption as an attribute", () => { + const node = imgNode({ + src: "https://prod.img.url", + alt: "An example", + caption: "Example of an OTP field", + }); + const result = format(node); + + expect(result).toContain('caption="Example of an OTP field"'); + }); + + it("omits caption content when caption is not set", () => { + const node = imgNode({ src: "https://prod.img.url" }); + const result = format(node); + + expect(result.trim()).toBe('{% img src="https://prod.img.url" /%}'); + }); + + it("renders video=true for video sources", () => { + const node = imgNode({ src: "https://prod.video.url", video: true }); + const result = format(node); + + expect(result).toContain('src="https://prod.video.url"'); + expect(result).toContain("video=true"); + expect(result).not.toContain("alt="); + }); + + it("has no errors", () => { + const node = imgNode({ src: "https://prod.img.url", alt: "An example" }); + expect(node.errors).toHaveLength(0); + }); +}); diff --git a/astro/src/components/Img/tests/ImgController.unit.test.ts b/astro/src/components/Img/tests/ImgController.unit.test.ts new file mode 100644 index 00000000000..6153bffd996 --- /dev/null +++ b/astro/src/components/Img/tests/ImgController.unit.test.ts @@ -0,0 +1,206 @@ +// @vitest-environment happy-dom +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/preact"; +import userEvent from "@testing-library/user-event"; +import { h } from "preact"; +import type { ComponentType } from "preact"; +import ImgController from "../ImgController"; + +const ImgControllerComponent = ImgController as ComponentType; + +const baseProps = { + imageUrl: "/images/content/example.png", + srcset: "/images/content/example.png", + popupHref: "/images/content/example.png?fit=max&auto=format", +}; + +const renderImgController = (props: Record = {}) => + render(h(ImgControllerComponent, { ...baseProps, ...props })); + +afterEach(() => { + cleanup(); + document.body.style.overflow = ""; +}); + +describe("ImgController — rendering", () => { + it("renders a figure-wrapped image by default", () => { + const { container } = renderImgController({ alt: "CI/CD Health dashboard" }); + + expect(container.querySelector(".img__figure")).not.toBeNull(); + const image = container.querySelector("img.img__image"); + expect(image).not.toBeNull(); + expect(image?.getAttribute("alt")).toBe("CI/CD Health dashboard"); + }); + + it("wraps the image in a popup link by default", () => { + const { container } = renderImgController(); + + const link = container.querySelector("a.img__link--popup"); + expect(link).not.toBeNull(); + expect(link?.getAttribute("href")).toBe(baseProps.popupHref); + }); + + it("omits the popup link when popup is false", () => { + const { container } = renderImgController({ popup: false }); + + expect(container.querySelector(".img__link--popup")).toBeNull(); + expect(container.querySelector("a")).toBeNull(); + }); + + it("renders a figcaption when caption is set", () => { + const { container } = renderImgController({ caption: "Example caption" }); + + const figcaption = container.querySelector("figcaption"); + expect(figcaption).not.toBeNull(); + expect(figcaption?.textContent).toBe("Example caption"); + }); + + it("omits the figcaption when caption is not set", () => { + const { container } = renderImgController(); + + expect(container.querySelector("figcaption")).toBeNull(); + }); + + it("renders a bare inline img with no figure or popup", () => { + const { container } = renderImgController({ inline: true, width: "22" }); + + expect(container.querySelector(".img__figure")).toBeNull(); + expect(container.querySelector("figure")).toBeNull(); + expect(container.querySelector("a")).toBeNull(); + const image = container.querySelector("img"); + expect(image).not.toBeNull(); + expect(image?.getAttribute("width")).toBe("22"); + }); + + it("applies widthPercent as an inline width style on the rendered image", () => { + const { container } = renderImgController({ widthPercent: 40 }); + + const image = container.querySelector("img.img__image") as HTMLElement; + expect(image.style.width).toBe("40%"); + }); + + it("omits the style attribute when widthPercent is not set", () => { + const { container } = renderImgController(); + + const image = container.querySelector("img.img__image")!; + expect(image.hasAttribute("style")).toBe(false); + }); +}); + +describe("ImgController — opening via its own trigger", () => { + it("opens the overlay with the full-size image when the popup link is clicked", async () => { + const user = userEvent.setup(); + const { container } = renderImgController({ alt: "An example screenshot" }); + + const trigger = container.querySelector("a.img__link--popup")!; + await user.click(trigger); + + const overlay = container.querySelector(".img-lightbox__overlay")!; + expect(overlay.hasAttribute("hidden")).toBe(false); + expect(overlay.getAttribute("aria-hidden")).toBe("false"); + + const lightboxImage = overlay.querySelector("img"); + expect(lightboxImage?.getAttribute("src")).toBe(baseProps.popupHref); + expect(lightboxImage?.getAttribute("alt")).toBe("An example screenshot"); + }); + + it("prevents the default navigation on the trigger link", async () => { + const user = userEvent.setup(); + const { container } = renderImgController(); + + const trigger = container.querySelector("a.img__link--popup")!; + await user.click(trigger); + + const clickEvent = new MouseEvent("click", { bubbles: true, cancelable: true }); + trigger.dispatchEvent(clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + }); + + it("shows the caption in the lightbox when one is set", async () => { + const user = userEvent.setup(); + const { container } = renderImgController({ caption: "Example caption text" }); + + const trigger = container.querySelector("a.img__link--popup")!; + await user.click(trigger); + + const overlay = container.querySelector(".img-lightbox__overlay")!; + expect(overlay.textContent).toContain("Example caption text"); + }); + + it("renders no overlay content before the trigger is clicked", () => { + const { container } = renderImgController(); + + const overlay = container.querySelector(".img-lightbox__overlay")!; + expect(overlay.hasAttribute("hidden")).toBe(true); + expect(overlay.querySelector("img")).toBeNull(); + }); + + it("never renders a close button", async () => { + const user = userEvent.setup(); + const { container } = renderImgController(); + + const trigger = container.querySelector("a.img__link--popup")!; + await user.click(trigger); + + const overlay = container.querySelector(".img-lightbox__overlay")!; + expect(overlay.querySelector("button")).toBeNull(); + }); +}); + +describe("ImgController — closing", () => { + async function openLightbox( + user: ReturnType, + container: HTMLElement, + ) { + const trigger = container.querySelector("a.img__link--popup")!; + await user.click(trigger); + } + + it("closes on Escape keypress and clears displayed image state", async () => { + const user = userEvent.setup(); + const { container } = renderImgController(); + await openLightbox(user, container); + + const overlay = container.querySelector(".img-lightbox__overlay")!; + expect(overlay.hasAttribute("hidden")).toBe(false); + + await user.keyboard("{Escape}"); + + expect(overlay.hasAttribute("hidden")).toBe(true); + expect(overlay.querySelector("img")).toBeNull(); + }); + + it("closes on backdrop click but stays open when the dialog itself is clicked", async () => { + const user = userEvent.setup(); + const { container } = renderImgController(); + await openLightbox(user, container); + + const overlay = container.querySelector(".img-lightbox__overlay")!; + const dialog = overlay.querySelector(".img-lightbox__dialog") as HTMLElement; + expect(dialog).not.toBeNull(); + + await user.click(dialog); + expect(overlay.hasAttribute("hidden")).toBe(false); + + await user.click(overlay); + expect(overlay.hasAttribute("hidden")).toBe(true); + }); +}); + +describe("ImgController — body scroll lock", () => { + it("locks body scroll while open and releases it on close", async () => { + const user = userEvent.setup(); + const { container } = renderImgController(); + + expect(document.body.style.overflow).toBe(""); + + const trigger = container.querySelector("a.img__link--popup")!; + await user.click(trigger); + + expect(document.body.style.overflow).toBe("hidden"); + + await user.keyboard("{Escape}"); + + expect(document.body.style.overflow).toBe(""); + }); +}); diff --git a/astro/src/components/Img/tests/ImgVideo.unit.test.ts b/astro/src/components/Img/tests/ImgVideo.unit.test.ts new file mode 100644 index 00000000000..a7b4fe80530 --- /dev/null +++ b/astro/src/components/Img/tests/ImgVideo.unit.test.ts @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/preact"; +import { h } from "preact"; +import type { ComponentType } from "preact"; +import ImgVideo from "../ImgVideo"; + +const ImgVideoComponent = ImgVideo as ComponentType; + +const baseProps = { + imageUrl: "/videos/content/example.mp4", +}; + +const renderImgVideo = (props: Record = {}) => + render(h(ImgVideoComponent, { ...baseProps, ...props })); + +describe("ImgVideo", () => { + it("renders a figure-wrapped video", () => { + const { container } = renderImgVideo(); + + expect(container.querySelector(".img__figure")).not.toBeNull(); + expect(container.querySelector("video.img__video")).not.toBeNull(); + }); + + it("renders with autoplay/loop/muted/controls behavior", () => { + const { container } = renderImgVideo(); + + const video = container.querySelector("video")!; + expect(video.hasAttribute("muted")).toBe(true); + expect(video.hasAttribute("playsinline")).toBe(true); + expect(video.hasAttribute("autoplay")).toBe(true); + expect(video.hasAttribute("loop")).toBe(true); + expect(video.hasAttribute("controls")).toBe(true); + }); + + it("points the video source at the resolved imageUrl", () => { + const { container } = renderImgVideo(); + + const source = container.querySelector("source"); + expect(source?.getAttribute("src")).toBe(baseProps.imageUrl); + expect(source?.getAttribute("type")).toBe("video/mp4"); + }); + + it("applies width/height attributes when set", () => { + const { container } = renderImgVideo({ width: "640", height: "360" }); + + const video = container.querySelector("video")!; + expect(video.getAttribute("width")).toBe("640"); + expect(video.getAttribute("height")).toBe("360"); + }); + + it("applies widthPercent as an inline width style", () => { + const { container } = renderImgVideo({ widthPercent: 40 }); + + const video = container.querySelector("video") as HTMLElement; + expect(video.style.width).toBe("40%"); + }); + + it("omits the style attribute when widthPercent is not set", () => { + const { container } = renderImgVideo(); + + const video = container.querySelector("video")!; + expect(video.hasAttribute("style")).toBe(false); + }); +}); diff --git a/astro/src/components/Img/tests/browser.test.ts b/astro/src/components/Img/tests/browser.test.ts new file mode 100644 index 00000000000..56127a57010 --- /dev/null +++ b/astro/src/components/Img/tests/browser.test.ts @@ -0,0 +1,95 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Img component — visual", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/dd_e2e/components/img"); + }); + + test("basic image matches screenshot", async ({ page }) => { + const figure = page.locator("main").locator(".img__figure").first(); + await expect(figure).toHaveScreenshot("img-basic.png"); + }); + + test("image with caption renders a figcaption", async ({ page }) => { + const figure = page + .locator("main") + .locator(".img__figure") + .filter({ has: page.locator("figcaption") }) + .first(); + await expect(figure.locator("figcaption")).toBeVisible(); + await expect(figure).toHaveScreenshot("img-caption.png"); + }); + + test("popup-disabled image has no link wrapper", async ({ page }) => { + const figures = page.locator("main").locator(".img__figure"); + const count = await figures.count(); + + let found = false; + for (let i = 0; i < count; i++) { + const figure = figures.nth(i); + if ((await figure.locator(".img__link--popup").count()) === 0) { + await expect(figure.locator("img")).toBeVisible(); + found = true; + break; + } + } + expect(found).toBe(true); + }); + + test("inline image renders without a figure wrapper", async ({ page }) => { + const inlineImg = page.locator("main").locator("p img.img__image").first(); + await expect(inlineImg).toBeVisible(); + }); + + test("video renders with controls", async ({ page }) => { + const video = page.locator("main").locator("video.img__video").first(); + await expect(video).toBeVisible(); + await expect(video).toHaveAttribute("controls", ""); + }); +}); + +test.describe("Img component — lightbox", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/dd_e2e/components/img"); + // client:idle hydrates asynchronously; wait for every ImgController + // island's click listener to attach so the trigger click doesn't fall + // through to a real navigation. + await page.waitForFunction(() => { + const islands = document.querySelectorAll( + 'astro-island[component-url*="ImgController"]', + ); + return ( + islands.length > 0 && + Array.from(islands).every((island) => island.getAttribute("ssr") === null) + ); + }); + }); + + test("clicking a popup-enabled image opens the lightbox with the full-size image", async ({ + page, + }) => { + const trigger = page.locator("main").locator(".img__link--popup").first(); + const expectedSrc = await trigger.getAttribute("href"); + + await trigger.click(); + + const overlay = page.locator(".img-lightbox__overlay:not([hidden])"); + await expect(overlay).toBeVisible(); + const lightboxImage = overlay.locator("img"); + await expect(lightboxImage).toHaveAttribute("src", expectedSrc ?? ""); + }); + + test("Escape closes the lightbox", async ({ page }) => { + const trigger = page.locator("main").locator(".img__link--popup").first(); + await trigger.click(); + + const overlay = page.locator(".img-lightbox__overlay:not([hidden])"); + await expect(overlay).toBeVisible(); + + // Without the timeout, the test is flaky under Under heavy parallel load. + await expect(async () => { + await page.keyboard.press("Escape"); + await expect(overlay).toBeHidden({ timeout: 500 }); + }).toPass(); + }); +}); diff --git a/astro/src/components/Img/tests/browser.test.ts-snapshots/img-basic-chromium-darwin.png b/astro/src/components/Img/tests/browser.test.ts-snapshots/img-basic-chromium-darwin.png new file mode 100644 index 00000000000..d2393218c12 Binary files /dev/null and b/astro/src/components/Img/tests/browser.test.ts-snapshots/img-basic-chromium-darwin.png differ diff --git a/astro/src/components/Img/tests/browser.test.ts-snapshots/img-caption-chromium-darwin.png b/astro/src/components/Img/tests/browser.test.ts-snapshots/img-caption-chromium-darwin.png new file mode 100644 index 00000000000..0a3dcef6bab Binary files /dev/null and b/astro/src/components/Img/tests/browser.test.ts-snapshots/img-caption-chromium-darwin.png differ diff --git a/astro/src/components/Img/tests/unit.test.ts b/astro/src/components/Img/tests/unit.test.ts new file mode 100644 index 00000000000..f1b25a7fe7a --- /dev/null +++ b/astro/src/components/Img/tests/unit.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { experimental_AstroContainer as AstroContainer } from "astro/container"; +// @ts-ignore — Preact renderer is registered for SSR of the ImgController island. +import preactRenderer from "@astrojs/preact/server.js"; +import Img from "../Img.astro"; + +async function renderImg(props: Record) { + const container = await AstroContainer.create(); + container.addServerRenderer({ + renderer: preactRenderer, + name: "@astrojs/preact", + }); + return container.renderToString(Img as never, { props }); +} + +describe("Img component", () => { + it("renders a figure-wrapped image by default", async () => { + const html = await renderImg({ + src: "cicd_optimization/cicd_health.png", + alt: "CI/CD Health dashboard", + }); + + expect(html).toContain("img__figure"); + expect(html).toContain(" { + const html = await renderImg({ src: "cicd_optimization/cicd_health.png" }); + + expect(html).toContain("srcset="); + expect(html).toContain("cicd_optimization/cicd_health.png"); + }); + + it("wraps the image in a popup link by default", async () => { + const html = await renderImg({ + src: "cicd_optimization/cicd_health.png", + alt: "CI/CD Health dashboard", + }); + + expect(html).toContain("img__link--popup"); + expect(html).toContain(" { + const html = await renderImg({ + src: "account_management/audit_logs/reference_tables.png", + popup: false, + }); + + expect(html).not.toContain("img__link--popup"); + expect(html).not.toContain(" { + const html = await renderImg({ + src: "synthetics/guide/otp-from-email-body/simple_otp.png", + caption: "Example of an OTP with a simple text field", + }); + + expect(html).toContain(" { + const html = await renderImg({ src: "cicd_optimization/cicd_health.png" }); + + expect(html).not.toContain(" { + const html = await renderImg({ + src: "metrics/guide/agent_filtering_for_custom_metrics/show_sidebar.png", + inline: true, + width: "22", + }); + + expect(html).not.toContain("img__figure"); + expect(html).not.toContain(" { + const html = await renderImg({ src: "ci/custom-tags-create-facet.mp4", video: true }); + + expect(html).toContain(" { + const html = await renderImg({ src: "ci/custom-tags-create-facet.mp4", video: true }); + + expect(html).toContain("muted"); + expect(html).toContain("playsinline"); + expect(html).toContain("autoplay"); + expect(html).toContain("loop"); + expect(html).toContain("controls"); + }); + + it("points the video source at the resolved src", async () => { + const html = await renderImg({ src: "ci/custom-tags-create-facet.mp4", video: true }); + + expect(html).toContain(" { + const html = await renderImg({ + src: "ci/custom-tags-create-facet.mp4", + video: true, + inline: true, + }); + + expect(html).toContain(" { + const html = await renderImg({ + src: "cicd_optimization/cicd_health.png", + widthPercent: 40, + }); + + expect(html).toMatch(/]*style="width:\s*40%;?"/); + }); + + it("omits the style attribute when widthPercent is not set", async () => { + const html = await renderImg({ src: "cicd_optimization/cicd_health.png" }); + + expect(html).not.toContain("style="); + }); +}); diff --git a/astro/src/config/images.ts b/astro/src/config/images.ts index 52d04f734e3..0805377ed44 100644 --- a/astro/src/config/images.ts +++ b/astro/src/config/images.ts @@ -1,2 +1,6 @@ -// TODO: make this environment-dependent (e.g. a staging CDN for preview builds) -export const IMAGES_URL = "https://imgix.datadoghq.com"; +const PROD_IMAGES_URL = "https://docs.dd-static.net"; +const DEV_IMAGES_URL = "http://docs-staging.dd-static.net"; + +export const IMAGES_URL = import.meta.env.PROD + ? PROD_IMAGES_URL + : DEV_IMAGES_URL; diff --git a/astro/src/content/en/dd_e2e/components/img.mdoc b/astro/src/content/en/dd_e2e/components/img.mdoc new file mode 100644 index 00000000000..d7fdb476666 --- /dev/null +++ b/astro/src/content/en/dd_e2e/components/img.mdoc @@ -0,0 +1,51 @@ +--- +title: "Image" +description: "Places an image or video in the content, ported from Hugo's `{{< img >}}` shortcode." +type: static +--- + +## Basic image + +{% img src="cicd_optimization/cicd_health.png" alt="CI/CD Health dashboard" widthPercent=100 /%} + +## Image in a tab + +{% tabs %} +{% tab label="Result" %} +{% img src="cicd_optimization/cicd_health.png" alt="CI/CD Health dashboard" widthPercent=100 /%} +{% /tab %} +{% tab label="Test" %} +Test +{% /tab %} +{% /tabs %} + +## With a caption + +{% img + src="synthetics/guide/otp-from-email-body/simple_otp.png" + alt="Example of an OTP with a simple text field" + widthPercent=40 + caption="Example of an OTP with a simple text field" +/%} + +## Popup disabled + +{% img + src="account_management/audit_logs/reference_tables.png" + alt="The Datadog Audit Trail explorer with reference table search options highlighted" + popup=false + widthPercent=100 +/%} + +## Inline image + +Hover over an app and click the edit {% img src="icons/pencil.png" inline=true width="22" height="22" /%} icon. + +## Video + +{% img + src="ci/custom-tags-create-facet.mp4" + alt="Facet creation for custom tag" + widthPercent=100 + video=true +/%} \ No newline at end of file diff --git a/astro/src/lib/plaintext/tests/twinTransform.unit.test.ts b/astro/src/lib/plaintext/tests/twinTransform.unit.test.ts index 79342956d91..dc573d32685 100644 --- a/astro/src/lib/plaintext/tests/twinTransform.unit.test.ts +++ b/astro/src/lib/plaintext/tests/twinTransform.unit.test.ts @@ -62,6 +62,54 @@ describe("renderMdocWithTwins", () => { expect(out).not.toContain("{% step"); }); + it("routes an img through its twin, resolving src to the CDN URL", () => { + const source = + '{% img src="cicd_optimization/cicd_health.png" alt="CI/CD Health dashboard" /%}'; + const out = renderMdocWithTwins(source); + + expect(out).toContain("{% img"); + expect(out).toContain( + 'src="http://docs-staging.dd-static.net/images/cicd_optimization/cicd_health.png"', + ); + expect(out).toContain('alt="CI/CD Health dashboard"'); + }); + + it("drops inline images from the output", () => { + const source = + '{% img src="icons/pencil.png" inline=true width="22" height="22" /%}'; + const out = renderMdocWithTwins(source); + + expect(out).not.toContain("{% img"); + }); + + it("drops layout-only attributes from img but keeps caption as an attribute", () => { + const source = [ + "{% img", + 'src="synthetics/guide/otp-from-email-body/simple_otp.png"', + 'alt="Example of an OTP with a simple text field"', + 'widthPercent=40', + 'caption="Example of an OTP with a simple text field"', + "/%}", + ].join(" "); + const out = renderMdocWithTwins(source); + + expect(out).not.toContain("widthPercent="); + expect(out).toContain( + 'caption="Example of an OTP with a simple text field"', + ); + }); + + it("marks a video img with video=true", () => { + const source = + '{% img src="ci/custom-tags-create-facet.mp4" alt="Facet creation" video=true /%}'; + const out = renderMdocWithTwins(source); + + expect(out).toContain( + 'src="http://docs-staging.dd-static.net/images/ci/custom-tags-create-facet.mp4"', + ); + expect(out).toContain("video=true"); + }); + it("transforms tags nested inside another twin", () => { const source = [ '{% collapse-content title="Outer" %}', diff --git a/astro/src/lib/plaintext/twinTransform.ts b/astro/src/lib/plaintext/twinTransform.ts index 2158f5e74e0..3986a01459c 100644 --- a/astro/src/lib/plaintext/twinTransform.ts +++ b/astro/src/lib/plaintext/twinTransform.ts @@ -25,6 +25,8 @@ import { stepperNodes, type StepInput, } from "@components/Stepper/plaintext/Stepper"; +import { imgNode } from "@components/Img/plaintext/Img"; +import { IMAGES_URL } from "@config/images"; /** * Adapts a parsed Markdoc tag node into its plaintext-twin equivalent. Most @@ -52,6 +54,16 @@ const twinAdaptersByTag: Record = { "agent-only": (node) => agentOnlyNode(transformNodes(node.children)), + img: (node) => { + if (attr(node, "inline")) return []; // Drop inline images from plaintext + return imgNode({ + src: `${IMAGES_URL}/images/${String(attr(node, "src") ?? "")}`, + alt: attr(node, "alt") as string | undefined, + caption: attr(node, "caption") as string | undefined, + video: attr(node, "video") as boolean | undefined, + }); + }, + stepper: (node) => { const steps: StepInput[] = []; let finished: MarkdocNode[] | undefined; diff --git a/astro/tests/headless/markdocSchema.test.ts b/astro/tests/headless/markdocSchema.test.ts new file mode 100644 index 00000000000..545e0baa6c0 --- /dev/null +++ b/astro/tests/headless/markdocSchema.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import schema from "../../markdoc.schema.mjs"; + +describe("img tag schema validate()", () => { + const validate = schema.tags.img.validate; + + it("errors when widthPercent is combined with width", () => { + const errors = validate({ attributes: { widthPercent: 40, width: "22" } }); + expect(errors).toHaveLength(1); + expect(errors[0].level).toBe("error"); + }); + + it("errors when widthPercent is combined with height", () => { + const errors = validate({ attributes: { widthPercent: 40, height: "22" } }); + expect(errors).toHaveLength(1); + expect(errors[0].level).toBe("error"); + }); + + it("errors when widthPercent is combined with both width and height", () => { + const errors = validate({ + attributes: { widthPercent: 40, width: "22", height: "22" }, + }); + expect(errors).toHaveLength(1); + }); + + it("allows widthPercent alone", () => { + const errors = validate({ attributes: { widthPercent: 40 } }); + expect(errors).toHaveLength(0); + }); + + it("allows width/height alone", () => { + const errors = validate({ attributes: { width: "22", height: "22" } }); + expect(errors).toHaveLength(0); + }); + + it("allows none of the three sizing attributes", () => { + const errors = validate({ attributes: {} }); + expect(errors).toHaveLength(0); + }); +});
{displayed.caption}