diff --git a/docs/syntax/images.md b/docs/syntax/images.md index b1c0f0b63..29b4e427e 100644 --- a/docs/syntax/images.md +++ b/docs/syntax/images.md @@ -120,3 +120,42 @@ image::images/metrics-alert-filters-and-group.png[Metric threshold filter and gr ```asciidoc image::images/synthetics-get-started-projects.png[] ``` + +## Image carousel + +The image carousel directive builds upon the image directive. + +```markdown +::::{carousel} + +:id: nested-carousel-example +:fixed-height: small ## small, medium, auto (auto is default if fixed-height is not specified) + +:::{image} images/apm.png +:alt: First image description +:title: First image title +::: + +:::{image} images/applies.png +:alt: Second image description +:title: Second image title +::: + +:::: +``` +::::{carousel} + +:id: nested-carousel-example +:fixed-height: small + +:::{image} images/apm.png +:alt: First image description +:title: First image title +::: + +:::{image} images/applies.png +:alt: Second image description +:title: Second image title +::: + +:::: diff --git a/src/Elastic.Documentation.Site/Assets/image-carousel.ts b/src/Elastic.Documentation.Site/Assets/image-carousel.ts new file mode 100644 index 000000000..3f3a4b163 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/image-carousel.ts @@ -0,0 +1,347 @@ +class ImageCarousel { + private container: HTMLElement + private slides: HTMLElement[] + private indicators: HTMLElement[] + private prevButton: HTMLElement | null + private nextButton: HTMLElement | null + private currentIndex: number = 0 + private touchStartX: number = 0 + private touchEndX: number = 0 + + constructor(container: HTMLElement) { + this.container = container + if (!this.container) { + console.warn('Carousel container element is null or undefined') + return + } + + this.slides = Array.from( + this.container.querySelectorAll('.carousel-slide') + ) + this.indicators = Array.from( + this.container.querySelectorAll('.carousel-indicator') + ) + this.prevButton = this.container.querySelector('.carousel-prev') + this.nextButton = this.container.querySelector('.carousel-next') + + this.initializeSlides() + this.setupEventListeners() + } + + private initializeSlides(): void { + // Initialize all slides as inactive + this.slides.forEach((slide, index) => { + this.setSlideState(slide, index === 0) + }) + + // Initialize indicators + this.indicators.forEach((indicator, index) => { + this.setIndicatorState(indicator, index === 0) + }) + } + + private setSlideState(slide: HTMLElement, isActive: boolean): void { + slide.setAttribute('data-active', isActive.toString()) + slide.style.display = isActive ? 'block' : 'none' + slide.style.opacity = isActive ? '1' : '0' + } + + private setIndicatorState(indicator: HTMLElement, isActive: boolean): void { + indicator.setAttribute('data-active', isActive.toString()) + } + + private setupEventListeners(): void { + // Navigation controls + this.prevButton?.addEventListener('click', () => this.prevSlide()) + this.nextButton?.addEventListener('click', () => this.nextSlide()) + + // Indicators + this.indicators.forEach((indicator, index) => { + indicator.addEventListener('click', () => this.goToSlide(index)) + }) + + // Keyboard navigation + document.addEventListener('keydown', (e) => { + if (!this.isInViewport()) return + + if (e.key === 'ArrowLeft') this.prevSlide() + else if (e.key === 'ArrowRight') this.nextSlide() + }) + + // Touch events + this.container.addEventListener('touchstart', (e) => { + this.touchStartX = e.changedTouches[0].screenX + }) + + this.container.addEventListener('touchend', (e) => { + this.touchEndX = e.changedTouches[0].screenX + this.handleSwipe() + }) + } + + private prevSlide(): void { + const newIndex = + (this.currentIndex - 1 + this.slides.length) % this.slides.length + this.goToSlide(newIndex) + } + + private nextSlide(): void { + const newIndex = (this.currentIndex + 1) % this.slides.length + this.goToSlide(newIndex) + } + + private goToSlide(index: number): void { + // Update slides + this.setSlideState(this.slides[this.currentIndex], false) + this.setSlideState(this.slides[index], true) + + // Update indicators + if (this.indicators.length > 0) { + this.setIndicatorState(this.indicators[this.currentIndex], false) + this.setIndicatorState(this.indicators[index], true) + } + + this.currentIndex = index + } + + private handleSwipe(): void { + const threshold = 50 + const diff = this.touchStartX - this.touchEndX + + if (Math.abs(diff) < threshold) return + + if (diff > 0) this.nextSlide() + else this.prevSlide() + } + + private isInViewport(): boolean { + const rect = this.container.getBoundingClientRect() + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= + (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= + (window.innerWidth || document.documentElement.clientWidth) + ) + } +} + +// Export function to initialize carousels +export function initImageCarousel(): void { + // Find all carousel containers + const carousels = document.querySelectorAll('.carousel-container') + + // Process each carousel + carousels.forEach((carouselElement) => { + const carousel = carouselElement as HTMLElement + + // Get the existing track + let track = carousel.querySelector('.carousel-track') + if (!track) { + track = document.createElement('div') + track.className = 'carousel-track' + carousel.appendChild(track) + } + + // Clean up any existing slides - this prevents duplicates + const existingSlides = Array.from( + track.querySelectorAll('.carousel-slide') + ) + + // Find all image links that might be related to this carousel + const section = findSectionForCarousel(carousel) + if (!section) return + + // First, collect all images we want in the carousel + const allImageLinks = Array.from( + section.querySelectorAll('a[href*="epr.elastic.co"]') + ) + + // Track URLs to prevent duplicates + const processedUrls = new Set() + + // Process the existing slides first + existingSlides.forEach((slide) => { + const imageRef = slide.querySelector('a.carousel-image-reference') + if (imageRef && imageRef instanceof HTMLAnchorElement) { + processedUrls.add(imageRef.href) + } + }) + + // Find standalone images (not already in carousel slides) + const standaloneImages = allImageLinks.filter((img) => { + if (processedUrls.has(img.href)) { + return false // Skip if already processed + } + + // Don't count images already in carousel slides + const isInCarousel = img.closest('.carousel-slide') !== null + if (isInCarousel) { + processedUrls.add(img.href) + return false + } + + processedUrls.add(img.href) + return true + }) + + // Add the standalone images to the carousel + let slideIndex = existingSlides.length + standaloneImages.forEach((imgLink) => { + // Find container to hide + const imgContainer = findClosestContainer(imgLink, carousel) + + // Create a new slide + const slide = document.createElement('div') + slide.className = 'carousel-slide' + slide.setAttribute('data-index', slideIndex.toString()) + if (slideIndex === 0 && existingSlides.length === 0) { + slide.setAttribute('data-active', 'true') + } + + // Create a proper carousel image reference wrapper + const imageRef = document.createElement('a') + imageRef.className = 'carousel-image-reference' + imageRef.href = imgLink.href + imageRef.target = '_blank' + + // Clone the image + const img = imgLink.querySelector('img') + if (img) { + imageRef.appendChild(img.cloneNode(true)) + } + + slide.appendChild(imageRef) + track.appendChild(slide) + + // Hide the original container properly + if (imgContainer) { + try { + // Find the parent element that might be a paragraph or div containing the image + let parent = imgContainer + let maxAttempts = 3 // Don't go too far up the tree + + while ( + maxAttempts > 0 && + parent && + parent !== document.body + ) { + // If this is one of these elements, hide it + if ( + parent.tagName === 'P' || + (parent.tagName === 'DIV' && + !parent.classList.contains( + 'carousel-container' + )) + ) { + parent.style.display = 'none' + break + } + parent = parent.parentElement + maxAttempts-- + } + + // If we couldn't find a suitable parent, just hide the container itself + if (maxAttempts === 0) { + imgContainer.style.display = 'none' + } + } catch (e) { + console.error('Failed to hide original image:', e) + } + } + + slideIndex++ + }) + + // Only set up controls if we have multiple slides + const totalSlides = track.querySelectorAll('.carousel-slide').length + if (totalSlides > 1) { + // Add controls if they don't exist + if (!carousel.querySelector('.carousel-prev')) { + const prevButton = document.createElement('button') + prevButton.type = 'button' + prevButton.className = 'carousel-control carousel-prev' + prevButton.setAttribute('aria-label', 'Previous slide') + prevButton.innerHTML = '' + carousel.appendChild(prevButton) + } + + if (!carousel.querySelector('.carousel-next')) { + const nextButton = document.createElement('button') + nextButton.type = 'button' + nextButton.className = 'carousel-control carousel-next' + nextButton.setAttribute('aria-label', 'Next slide') + nextButton.innerHTML = '' + carousel.appendChild(nextButton) + } + + // Add or update indicators + let indicators = carousel.querySelector('.carousel-indicators') + if (!indicators) { + indicators = document.createElement('div') + indicators.className = 'carousel-indicators' + carousel.appendChild(indicators) + } else { + indicators.innerHTML = '' // Clear existing indicators + } + + for (let i = 0; i < totalSlides; i++) { + const indicator = document.createElement('button') + indicator.type = 'button' + indicator.className = 'carousel-indicator' + indicator.setAttribute('data-index', i.toString()) + if (i === 0) { + indicator.setAttribute('data-active', 'true') + } + indicator.setAttribute('aria-label', `Go to slide ${i + 1}`) + indicators.appendChild(indicator) + } + } + + // Initialize this carousel + new ImageCarousel(carousel) + }) +} + +// Helper to find a suitable container for an image +function findClosestContainer( + element: Element, + carousel: Element +): Element | null { + let current = element + while ( + current && + !current.contains(carousel) && + current !== document.body + ) { + // Stop at these elements + if ( + current.tagName === 'P' || + current.tagName === 'DIV' || + current.classList.contains('carousel-container') + ) { + return current + } + current = current.parentElement! + } + return element +} + +// Helper to find the section containing a carousel +function findSectionForCarousel(carousel: Element): Element | null { + // Look for containing section, article, or main element + let section = carousel.closest( + 'section, article, main, div.markdown-content' + ) + if (!section) { + // Fallback to parent element + section = carousel.parentElement + } + return section +} + +// Initialize all carousels when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + initImageCarousel() +}) diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index de8404f8c..febec0afe 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -1,6 +1,7 @@ import { initCopyButton } from './copybutton' import { initDismissibleBanner } from './dismissible-banner' import { initHighlight } from './hljs' +import { initImageCarousel } from './image-carousel' import { openDetailsWithAnchor } from './open-details-with-anchor' import { initNav } from './pages-nav' import { initSmoothScroll } from './smooth-scroll' @@ -25,6 +26,8 @@ document.addEventListener('htmx:load', function () { initSmoothScroll() openDetailsWithAnchor() initDismissibleBanner() + initImageCarousel() + tippy('[data-tippy-content]:not([data-tippy-content=""])', { delay: [400, 100], }) diff --git a/src/Elastic.Documentation.Site/Assets/markdown/image-carousel.css b/src/Elastic.Documentation.Site/Assets/markdown/image-carousel.css new file mode 100644 index 000000000..1a5480e42 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/markdown/image-carousel.css @@ -0,0 +1,139 @@ +.carousel-container { + position: relative; + width: 100%; + margin: 2rem 0; + overflow: hidden; +} + +.carousel-track { + width: 100%; + position: relative; + min-height: 200px; +} + +.carousel-slide { + width: 100%; + position: absolute; + display: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.carousel-slide[data-active='true'] { + position: relative; + display: block; + opacity: 1; + z-index: 2; +} + +.carousel-image-reference { + display: block; +} + +.carousel-image-reference img { + width: 100%; + height: auto; + display: block; +} + +.carousel-control { + position: absolute; + top: 50%; + transform: translateY(-50%); + background-color: rgba(0, 0, 0, 0.5); + border: none; + color: white; + font-size: 1.5rem; + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.3s; + z-index: 5; +} + +.carousel-control:hover { + background-color: rgba(0, 0, 0, 0.7); +} + +.carousel-prev { + left: 10px; +} + +.carousel-next { + right: 10px; +} + +.carousel-indicators { + position: absolute; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 8px; + z-index: 5; +} + +.carousel-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0.3); + border: none; + cursor: pointer; + padding: 0; + transition: background-color 0.3s; +} + +.carousel-indicator[data-active='true'] { + background-color: black; +} + +/* Fixed height carousel styles */ +.carousel-container[data-fixed-height] .carousel-track { + min-height: auto; + overflow: hidden; +} + +.carousel-container[data-fixed-height] .carousel-slide { + height: 100%; + top: 0; + left: 0; +} + +.carousel-container[data-fixed-height] .carousel-slide[data-active='true'] { + position: relative; + height: 100%; + top: auto; + left: auto; +} + +.carousel-container[data-fixed-height] .carousel-image-reference { + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.carousel-container[data-fixed-height] .carousel-image-reference img { + width: auto; + height: 100%; + max-width: 100%; + object-fit: contain; + object-position: center; +} +@media (max-width: 768px) { + .carousel-control { + width: 30px; + height: 30px; + font-size: 1.2rem; + } + + .carousel-indicator { + width: 10px; + height: 10px; + } +} diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index 1702e69fa..730e1bf1a 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -13,6 +13,7 @@ @import './markdown/table.css'; @import './markdown/definition-list.css'; @import './markdown/images.css'; +@import './markdown/image-carousel.css'; @import './modal.css'; @import './archive.css'; @import './markdown/stepper.css'; diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index b93ffb338..5666cfdc1 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -94,6 +94,9 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) if (info.IndexOf("{image}") > 0) return new ImageBlock(this, context); + if (info.IndexOf("{carousel}") > 0) + return new ImageCarouselBlock(this, context); + if (info.IndexOf("{figure}") > 0) return new FigureBlock(this, context); diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 2c2a6cd28..5bbfb85f0 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -47,6 +47,9 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo case ImageBlock imageBlock: WriteImage(renderer, imageBlock); return; + case ImageCarouselBlock carouselBlock: + WriteImageCarousel(renderer, carouselBlock); + return; case DropdownBlock dropdownBlock: WriteDropdown(renderer, dropdownBlock); return; @@ -103,7 +106,7 @@ private static void WriteImage(HtmlRenderer renderer, ImageBlock block) Alt = block.Alt ?? string.Empty, Title = block.Title, Height = block.Height, - Scale = block.Scale, + Scale = block.Scale ?? string.Empty, Target = block.Target, Width = block.Width, Screenshot = block.Screenshot, @@ -112,6 +115,30 @@ private static void WriteImage(HtmlRenderer renderer, ImageBlock block) RenderRazorSlice(slice, renderer); } + private static void WriteImageCarousel(HtmlRenderer renderer, ImageCarouselBlock block) + { + var slice = ImageCarouselView.Create(new ImageCarouselViewModel + { + DirectiveBlock = block, + Images = block.Images.Select(img => new ImageViewModel + { + DirectiveBlock = img, + Label = img.Label, + Align = img.Align ?? string.Empty, + Alt = img.Alt ?? string.Empty, + Title = img.Title, + Height = img.Height, + Width = img.Width, + Scale = img.Scale ?? string.Empty, + Screenshot = img.Screenshot, + Target = img.Target, + ImageUrl = img.ImageUrl + }).ToList(), + FixedHeight = block.FixedHeight + }); + RenderRazorSlice(slice, renderer); + } + private static void WriteStepperBlock(HtmlRenderer renderer, StepperBlock block) { var slice = StepperView.Create(new StepperViewModel { DirectiveBlock = block }); @@ -139,11 +166,11 @@ private static void WriteFigure(HtmlRenderer renderer, ImageBlock block) { DirectiveBlock = block, Label = block.Label, - Align = block.Align, + Align = block.Align ?? string.Empty, Alt = block.Alt ?? string.Empty, Title = block.Title, Height = block.Height, - Scale = block.Scale, + Scale = block.Scale ?? string.Empty, Target = block.Target, Width = block.Width, Screenshot = block.Screenshot, diff --git a/src/Elastic.Markdown/Myst/Directives/Image/ImageCarouselBlock.cs b/src/Elastic.Markdown/Myst/Directives/Image/ImageCarouselBlock.cs new file mode 100644 index 000000000..c5597c4ff --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Image/ImageCarouselBlock.cs @@ -0,0 +1,51 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.Collections.Generic; +using System.Linq; +using Elastic.Markdown.Diagnostics; +using Elastic.Markdown.Myst; + +namespace Elastic.Markdown.Myst.Directives.Image; + +public class ImageCarouselBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) +{ + public List Images { get; } = []; + public string? Id { get; set; } + public string? FixedHeight { get; set; } + + public override string Directive => "carousel"; + + public override void FinalizeAndValidate(ParserContext context) + { + // Parse options + Id = Prop("id"); + FixedHeight = Prop("fixed-height"); + + // Validate fixed-height option + if (!string.IsNullOrEmpty(FixedHeight)) + { + var validHeights = new[] { "auto", "small", "medium" }; + if (!validHeights.Contains(FixedHeight.ToLower())) + { + this.EmitWarning($"Invalid fixed-height value '{FixedHeight}'. Valid options are: auto, small, medium. Defaulting to 'auto'."); + } + } + + // Process child image blocks directly + foreach (var block in this) + { + if (block is ImageBlock imageBlock) + { + Images.Add(imageBlock); + } + } + + if (Images.Count == 0) + { + this.EmitError("carousel directive requires nested image directives"); + } + } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Image/ImageCarouselView.cshtml b/src/Elastic.Markdown/Myst/Directives/Image/ImageCarouselView.cshtml new file mode 100644 index 000000000..1d59c6d78 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Image/ImageCarouselView.cshtml @@ -0,0 +1,51 @@ +@inherits RazorSlice +@{ + // Convert semantic height values to pixel values + string? pixelHeight = Model.FixedHeight?.ToLower() switch + { + "small" => "350px", + "medium" => "750px", + "auto" or null or "" => null, + _ => null // Default to none for invalid values + }; + + var hasFixedHeight = !string.IsNullOrEmpty(pixelHeight); + var trackStyle = hasFixedHeight ? $"height: {pixelHeight};" : ""; +} + diff --git a/src/Elastic.Markdown/Myst/Directives/Image/ImageCarouselViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Image/ImageCarouselViewModel.cs new file mode 100644 index 000000000..d241aa472 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Image/ImageCarouselViewModel.cs @@ -0,0 +1,15 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text; +using Elastic.Documentation.Extensions; +using Elastic.Markdown.Myst.Directives.Image; + +namespace Elastic.Markdown.Myst.Directives.Image; + +public class ImageCarouselViewModel : DirectiveViewModel +{ + public required List Images { get; init; } + public string? FixedHeight { get; init; } +} diff --git a/tests/Elastic.Markdown.Tests/Directives/ImageCarouselTests.cs b/tests/Elastic.Markdown.Tests/Directives/ImageCarouselTests.cs new file mode 100644 index 000000000..f2d142fe3 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ImageCarouselTests.cs @@ -0,0 +1,210 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions.TestingHelpers; +using Elastic.Documentation.Diagnostics; +using Elastic.Markdown.Myst.Directives; +using Elastic.Markdown.Myst.Directives.Image; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Directives; + +public class ImageCarouselBlockTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{carousel} +:fixed-height: medium + +```{image} img/image1.png +:alt: First image +``` + +```{image} img/image2.png +:alt: Second image +``` +::: +""" +) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + fileSystem.AddFile(@"docs/img/image1.png", ""); + fileSystem.AddFile(@"docs/img/image2.png", ""); + } + + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); + + [Fact] + public void ParsesCarouselProperties() + { + Block!.FixedHeight.Should().Be("medium"); + } + + [Fact] + public void ProcessesNestedImages() + { + Block!.Images.Should().HaveCount(2); + Block!.Images[0].Alt.Should().Be("First image"); + Block!.Images[0].ImageUrl.Should().Be("/img/image1.png"); + Block!.Images[1].Alt.Should().Be("Second image"); + Block!.Images[1].ImageUrl.Should().Be("/img/image2.png"); + } + + [Fact] + public void AllImagesFoundSoNoErrorIsEmitted() + { + Block!.Images.Should().AllSatisfy(img => img.Found.Should().BeTrue()); + Collector.Diagnostics.Count.Should().Be(0); + } +} + +public class ImageCarouselWithSmallHeightTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{carousel} +:fixed-height: small + +```{image} img/small.png +:alt: Small image +``` +::: +""" +) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) => + fileSystem.AddFile(@"docs/img/small.png", ""); + + [Fact] + public void ParsesSmallFixedHeight() + { + Block!.FixedHeight.Should().Be("small"); + Collector.Diagnostics.Count.Should().Be(0); + } +} + +public class ImageCarouselWithAutoHeightTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{carousel} +:fixed-height: auto + +```{image} img/auto.png +:alt: Auto height image +``` +::: +""" +) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) => + fileSystem.AddFile(@"docs/img/auto.png", ""); + + [Fact] + public void ParsesAutoFixedHeight() + { + Block!.FixedHeight.Should().Be("auto"); + Collector.Diagnostics.Count.Should().Be(0); + } +} + +public class ImageCarouselWithInvalidHeightTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{carousel} +:fixed-height: large + +```{image} img/invalid.png +:alt: Invalid height image +``` +::: +""" +) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) => + fileSystem.AddFile(@"docs/img/invalid.png", ""); + + [Fact] + public void WarnsOnInvalidFixedHeight() + { + Block!.FixedHeight.Should().Be("large"); + + Collector.Diagnostics.Should().HaveCount(1) + .And.OnlyContain(d => d.Severity == Severity.Warning); + + var warning = Collector.Diagnostics.First(); + warning.Message.Should().Contain("Invalid fixed-height value 'large'"); + warning.Message.Should().Contain("Valid options are: auto, small, medium"); + } +} + +public class ImageCarouselWithoutImagesTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{carousel} +::: +""" +) +{ + [Fact] + public void EmitsErrorForEmptyCarousel() + { + Block!.Images.Should().BeEmpty(); + + Collector.Diagnostics.Should().HaveCount(1) + .And.OnlyContain(d => d.Severity == Severity.Error); + + var error = Collector.Diagnostics.First(); + error.Message.Should().Be("carousel directive requires nested image directives"); + } +} + +public class ImageCarouselMinimalTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{carousel} + +```{image} img/minimal.png +:alt: Minimal carousel +``` +::: +""" +) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) => + fileSystem.AddFile(@"docs/img/minimal.png", ""); + + [Fact] + public void ParsesMinimalCarousel() + { + Block!.FixedHeight.Should().BeNull(); + Block!.Images.Should().HaveCount(1); + Block!.Images[0].Alt.Should().Be("Minimal carousel"); + Collector.Diagnostics.Count.Should().Be(0); + } +} + +public class ImageCarouselWithMissingImageTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{carousel} + +```{image} img/missing.png +:alt: Missing image +``` + +```{image} img/exists.png +:alt: Existing image +``` +::: +""" +) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) => + fileSystem.AddFile(@"docs/img/exists.png", ""); + + [Fact] + public void HandlesPartiallyMissingImages() + { + Block!.Images.Should().HaveCount(2); + Block!.Images[0].Found.Should().BeFalse(); // missing.png + Block!.Images[1].Found.Should().BeTrue(); // exists.png + + // Should have diagnostics for the missing image + Collector.Diagnostics.Should().HaveCount(1) + .And.OnlyContain(d => d.Severity == Severity.Error); + } +}