Skip to content

feat(web): image pipeline v2 (next-gen, responsive, budgets) + theme contract v2#66

Merged
PrzemyslawKlys merged 1 commit intov2-speedgonzalesfrom
feat/image-pipeline-v2-contract-v2-20260206
Feb 6, 2026
Merged

feat(web): image pipeline v2 (next-gen, responsive, budgets) + theme contract v2#66
PrzemyslawKlys merged 1 commit intov2-speedgonzalesfrom
feat/image-pipeline-v2-contract-v2-20260206

Conversation

@PrzemyslawKlys
Copy link
Member

Summary

  • extend image optimizer with next-gen/responsive pipeline features:
    • optional WebP/AVIF generation
    • optional preferred next-gen src rewrites
    • optional responsive srcset generation
    • optional loading/decoding image hint injection
  • add image budget controls (max-bytes-per-file, max-total-bytes) and fail gates in CLI/pipeline/publish
  • extend optimize result payload with variant/rewrite/hint/budget visibility
  • add theme contract v2 fields (contractVersion, slots) + verifier checks and docs updates
  • update pipeline/publish/theme schemas for new fields
  • add tests for image variants/hints, image budget warnings, and theme contract v2 slot validation

Validation

  • dotnet test PowerForge.Tests/PowerForge.Tests.csproj -c Release
  • dotnet build PSPublishModule.sln -c Release
  • IntelligenceX/Website ./build.ps1
  • CodeMatrix/Website ./build.ps1

Copilot AI review requested due to automatic review settings February 6, 2026 12:18
@PrzemyslawKlys PrzemyslawKlys merged commit 25c102f into v2-speedgonzales Feb 6, 2026
8 checks passed
@PrzemyslawKlys PrzemyslawKlys deleted the feat/image-pipeline-v2-contract-v2-20260206 branch February 6, 2026 12:18
@claude
Copy link

claude bot commented Feb 6, 2026

Pull Request Review: Image Pipeline v2, Budgets, and Theme Contract v2

Summary

This PR introduces substantial new functionality for image optimization (next-gen formats, responsive variants, budgets) and theme contract v2 (slots). The implementation is generally well-structured with good test coverage, but I've identified several areas for improvement.


✅ Strengths

  1. Excellent Test Coverage: New features are well-covered with unit tests for variants, hints, budgets, and theme contract v2 validation
  2. Comprehensive Documentation: Both Pipeline.md and Theme.md are updated with clear examples
  3. Backward Compatibility: All new features are opt-in with sensible defaults
  4. Well-Structured Code: Clear separation of concerns with internal helper classes like ImageVariantPlan and ImageRewritePlan
  5. Schema Updates: JSON schemas properly updated for all new fields with both camelCase and kebab-case variants

🐛 Potential Bugs & Issues

Critical Issues

1. Memory Leak Risk in WebAssetOptimizer.cs:584-586

optimizedStream.Position = 0;
using var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None);
optimizedStream.CopyTo(fileStream);

The previous implementation used File.WriteAllBytes(file, stream.ToArray()) which was inefficient but safer. The new approach resets position and copies, but doesn't explicitly flush before disposing. Consider adding explicit flush or using CopyToAsync with proper async patterns.

2. Race Condition in RewriteHtmlImageTags (line 862+)
The method reads HTML files, modifies them in memory, then writes them back. If the optimize step runs concurrently with other operations or multiple images reference the same HTML file, there's potential for lost updates. Consider file locking or ensuring single-threaded execution during HTML rewrites.

3. Budget Validation Timing (line 830)

if (options.ImageMaxBytesPerFile > 0 && finalBytes > options.ImageMaxBytesPerFile)

This checks budget AFTER optimization but doesn't account for generated variants (WebP/AVIF). A 500KB image optimized to 400KB might generate a 300KB WebP variant, but budget only checks the 400KB version. Budget validation should include variant sizes.

Medium Issues

4. Missing Null Checks in TryResolveImageReference (line 953+)

var htmlDir = Path.GetDirectoryName(htmlFile) ?? siteRoot;
var candidate = Path.GetFullPath(Path.Combine(htmlDir, baseUrl.Replace('/', Path.DirectorySeparatorChar)));

If baseUrl contains invalid path characters or path traversal attempts (../../etc/passwd), this could throw or produce unexpected results. Add validation for baseUrl before path operations.

5. Regex Timeout Risk
Multiple new regex patterns (ImgTagRegex, ImgSrcAttrRegex, etc.) use RegexTimeout constant, but I don't see where it's defined in the diff. Ensure it's set to a reasonable value (e.g., 5 seconds) to prevent ReDoS attacks on malformed HTML.

6. Width Integer Overflow (line 573, 709)

imageWidth = (int)image.Width;

MagickImage.Width is uint but cast to int. Images wider than Int32.MaxValue (2.1GB+ width) will overflow. While rare, add bounds checking or use long.

Minor Issues

7. Inconsistent Error Handling
Line 1040: TryEncodeVariant catches all exceptions and returns false, but logs warning. However, the main optimization loop (line 570) also catches exceptions. This creates double-logging. Consider propagating specific exceptions or consolidating error handling.

8. SupportsQualitySetting Refactoring (line 1111)
The old string-based check was replaced with enum pattern matching:

return format is MagickFormat.Jpeg or MagickFormat.Jpg or MagickFormat.WebP or MagickFormat.Heic or MagickFormat.Heif or MagickFormat.Avif;

This is better, but MagickFormat.Jpg might not exist in all ImageMagick versions. Verify this doesn't introduce runtime errors.

9. Missing Validation for ResponsiveImageWidths
Line 545-549 filters and sorts widths, but doesn't validate against source image width. If all responsive widths are larger than the source, the loop at line 793 will skip all of them silently. Consider warning users.


⚠️ Performance Considerations

1. HTML Rewriting Overhead
RewriteHtmlImageTags loads entire HTML files into memory and uses regex replacement. For large sites with many HTML files and images:

  • Recommendation: Consider streaming HTML parsing or limiting to files that actually contain <img> tags (quick pre-scan)
  • Impact: For a 1000-page site, this could add significant build time

2. Image Variant Generation
For each image, the code potentially generates:

  • 1 optimized source
  • 1 WebP variant
  • 1 AVIF variant
  • N responsive variants per format

For 100 images with 3 responsive widths, that's 100 × (1 + 2 × 3) = 700 image operations.

  • Recommendation: Consider parallelizing variant generation with Parallel.ForEach or async/await patterns
  • Consideration: Memory usage could spike with many large images in memory

3. Dictionary Lookups
Line 879: rewritePlans.TryGetValue(resolvedRelative, out var plan) is called for every <img> tag in every HTML file. With thousands of images, this is efficient (O(1)), but consider:

  • Pre-filtering HTML files that don't need rewriting
  • Caching resolved paths to avoid repeated path resolution

🔒 Security Concerns

1. Path Traversal in TryWriteVariant (line 1069)

var relativeNoExt = Path.Combine(
    Path.GetDirectoryName(sourceRelativePath) ?? string.Empty,
    Path.GetFileNameWithoutExtension(sourceRelativePath))
    .Replace('\\', '/');
var variantName = width.HasValue
    ? $"{relativeNoExt}.w{width.Value}.{ext}"
    : $"{relativeNoExt}.{ext}";
var variantPath = Path.Combine(siteRoot, variantName.Replace('/', Path.DirectorySeparatorChar));

If sourceRelativePath contains .. or absolute path components, this could write files outside siteRoot.

  • Fix: Use Path.GetFullPath and validate IsUnderRoot before writing

2. Regex DoS Vulnerability
New regex patterns parse user-controlled HTML. Malicious HTML with deeply nested or pathological patterns could cause catastrophic backtracking.

  • Mitigation: Already using RegexTimeout, but verify it's set appropriately. Consider using RegexOptions.NonBacktracking (.NET 7+) for security-critical patterns.

3. Image Format Detection
Line 1087: ResolveMagickFormatForExtension has a default fallback to MagickFormat.WebP. If an attacker provides an unexpected extension, it silently becomes WebP. Consider rejecting unknown formats or at least logging.


📝 Code Quality & Best Practices

Good Practices

  • Proper use of using statements for disposal
  • Clear naming conventions (e.g., TryEncodeVariant, BuildUrlForReference)
  • Separation of concerns with helper methods

Areas for Improvement

1. Magic Numbers

  • Line 420: srcset entries use hardcoded 100vw for sizes attribute
  • Consider making this configurable or calculating based on breakpoints

2. Command-Line Parsing
Program.cs line 379: ParseIntListOption duplicates comma/semicolon parsing logic that exists elsewhere. Consider extracting to shared utility.

3. Theme Contract Validation
WebSiteVerifier.cs line 1153+: ValidateThemeSlots checks for missing partials but doesn't validate slot name conventions (e.g., reserved names, special characters). Consider adding format validation.

4. Missing Configuration
The PR adds many image-related options but no way to configure:

  • Maximum responsive variants per image
  • Preferred next-gen format priority (WebP vs AVIF)
  • Variant naming convention (currently hardcoded as .w{width}.{ext})

🧪 Test Coverage Assessment

Well Tested ✅

  • Next-gen variant generation (WebP)
  • Responsive variant generation
  • HTML rewriting with srcset/hints
  • Budget validation
  • Theme contract v2 slot validation

Missing Test Coverage ⚠️

  1. AVIF generation: Tests only check WebP, not AVIF
  2. Edge cases:
    • Images smaller than all responsive widths
    • HTML with existing srcset attributes
    • Relative vs absolute image paths in HTML
    • Multiple images referencing same source in one HTML file
  3. Error scenarios:
    • Corrupted images
    • Unwritable variant directories
    • HTML files with malformed img tags
  4. Budget validation: Only tests when budgets are exceeded, not when barely under budget
  5. Theme contract v2: Missing tests for valid v2 themes with all fields set correctly

Recommendations

  • Add test for image with both WebP and AVIF generation
  • Add test for relative image paths in subdirectories
  • Add test for HTML with pre-existing srcset (should not duplicate)
  • Add test for theme contract v2 with valid configuration

🚀 Suggested Improvements

High Priority

  1. Fix path traversal vulnerability in variant generation
  2. Add bounds checking for image width conversion
  3. Validate budget across all variants, not just source
  4. Add file locking or ensure single-threaded HTML rewrites

Medium Priority

  1. Parallelize image variant generation for performance
  2. Add configuration for srcset sizes calculation
  3. Improve error messages when budgets are exceeded (show which files)
  4. Add telemetry for optimization effectiveness (variants generated vs used)

Low Priority

  1. Extract parsing utilities to reduce duplication
  2. Add slot name validation for theme contract v2
  3. Consider progressive enhancement (load WebP then fall back to AVIF)
  4. Add debug logging for rewrite operations

📋 Checklist for Author

Before merging:

  • Verify MagickFormat.Jpg enum exists in your ImageMagick version
  • Confirm RegexTimeout constant is defined and appropriate
  • Add path traversal validation in TryWriteVariant
  • Consider adding AVIF test coverage
  • Document performance implications for large sites
  • Consider adding configuration for srcset sizes attribute

Final Recommendation

APPROVE WITH SUGGESTIONS

This is a well-implemented feature that adds significant value to the PowerForge.Web pipeline. The code quality is generally high, documentation is thorough, and test coverage is good. However, please address:

  1. Critical: Path traversal vulnerability in variant generation (line 1077)
  2. Critical: Budget validation should include all variants, not just source
  3. Important: Add bounds checking for image width integer overflow
  4. Nice to have: Additional test coverage for AVIF and edge cases

The security issues are addressable with small fixes, and the feature is valuable enough to merge with these addressed. Great work on the comprehensive implementation!


🤖 Generated with Claude Code (Sonnet 4.5)

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b47a56fb70

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

continue;
}

var path = loader.ResolvePartialPath(themeRoot, manifest, partialName);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate slot file paths directly

ValidateThemeSlots passes each slot value into ResolvePartialPath, but that resolver treats the input as a partial key/name and prefixes partialsPath when no map key matches. For contract v2 themes that define slots as explicit relative paths (for example partials/slots/hero.html, as documented), this produces a doubled path like partials/partials/... and raises a false “missing partial” warning even when the file exists, which can fail CI flows that gate on warnings.

Useful? React with 👍 / 👎.


if (srcsetEntries.Count > 0)
{
attrsUpdated += $" srcset=\"{string.Join(", ", srcsetEntries)}\"";

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rewrite generated srcset URLs after hashing

This adds srcset entries for responsive variants, but the later hash-reference rewrite path only updates href/src attributes, not srcset. In configurations that hash image extensions (for example .webp/.avif), HashAssets renames the generated variant files while srcset keeps pre-hash names, so responsive image requests resolve to missing files.

Useful? React with 👍 / 👎.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request introduces a comprehensive image optimization pipeline v2 with next-generation format support (WebP/AVIF), responsive image variants, performance hints, and budget controls. It also adds theme contract v2 with slot-based extensibility for improved theme portability and reusability. The changes span schemas, core services, models, CLI integration, tests, and documentation.

Changes:

  • Image pipeline v2: WebP/AVIF generation, responsive srcset, lazy loading hints, and byte budgets with fail gates
  • Theme contract v2: contractVersion field, slots mapping, and stricter v2 validation rules
  • Schema updates for new publish/pipeline/theme fields with both camelCase and kebab-case support
  • CLI arguments for all new image pipeline features and budget enforcement
  • Comprehensive test coverage for image variants, budgets, and theme slot validation
  • Documentation updates for theme contract v2 and pipeline optimize step

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
schemas/powerforge.web.themespec.schema.json Added contractVersion enum and slots dictionary for theme contract v2
schemas/powerforge.web.publishspec.schema.json Added image pipeline v2 fields: next-gen formats, responsive widths, hints, budgets
schemas/powerforge.web.pipelinespec.schema.json Added image pipeline fields with camelCase and kebab-case variants for CLI flexibility
PowerForge.Web/Services/WebSiteVerifier.cs Implemented theme contract v2 validation: version checks, slot validation, v2-specific requirements
PowerForge.Web/Services/WebAssetOptimizer.cs Implemented image variant generation, HTML rewriting, srcset injection, lazy loading hints, budget tracking
PowerForge.Web/Services/ThemeLoader.cs Added contractVersion and slots merging in theme inheritance
PowerForge.Web/Models/ThemeManifest.cs Added ContractVersion and Slots properties to theme manifest model
PowerForge.Web/Models/WebPublishSpec.cs Added image pipeline v2 configuration properties
PowerForge.Web/Models/WebLlmsResult.cs Added result tracking for variants, HTML rewrites, hints, and budget warnings
PowerForge.Web.Cli/Program.cs Integrated image pipeline v2 CLI arguments, budget fail gates, result reporting
PowerForge.Tests/WebSiteVerifierTests.cs Added test for theme contract v2 slot validation warnings
PowerForge.Tests/WebSiteAuditOptimizeBuildTests.cs Added tests for image variant generation, hints injection, and budget enforcement
Docs/PowerForge.Web.Theme.md Updated theme documentation for contract v2, slots, and portability guidelines
Docs/PowerForge.Web.Pipeline.md Updated optimize step documentation with image pipeline v2 features
Comments suppressed due to low confidence (36)

PowerForge.Web/Services/WebAssetOptimizer.cs:362

        foreach (var rewrite in rewrites)
        {
            if (string.IsNullOrWhiteSpace(rewrite.Source) || string.IsNullOrWhiteSpace(rewrite.Destination))
                continue;

            var source = Path.GetFullPath(rewrite.Source);
            if (!File.Exists(source)) continue;

            var destRelative = rewrite.Destination.TrimStart('/', '\\');
            if (!TryResolveUnderRoot(siteRoot, destRelative, out var dest))
            {
                Trace.TraceWarning($"Asset rewrite destination outside site root: {rewrite.Destination}");
                continue;
            }
            Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
            File.Copy(source, dest, overwrite: true);
        }

PowerForge.Web/Services/WebAssetOptimizer.cs:414

        foreach (var rewrite in rewrites)
        {
            if (string.IsNullOrWhiteSpace(rewrite.Match)) continue;
            var kind = rewrite.MatchType?.ToLowerInvariant() ?? "contains";
            switch (kind)
            {
                case "exact":
                    if (string.Equals(url, rewrite.Match, StringComparison.OrdinalIgnoreCase))
                        return rewrite.Replace;
                    break;
                case "prefix":
                    if (url.StartsWith(rewrite.Match, StringComparison.OrdinalIgnoreCase))
                        return rewrite.Replace + url.Substring(rewrite.Match.Length);
                    break;
                case "regex":
                    try
                    {
                        var regex = new Regex(rewrite.Match, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, RegexTimeout);
                        if (regex.IsMatch(url))
                            return regex.Replace(url, rewrite.Replace);
                    }
                    catch (Exception ex)
                    {
                        Trace.TraceWarning($"Invalid rewrite regex '{rewrite.Match}': {ex.GetType().Name}: {ex.Message}");
                    }
                    break;
                default:
                    if (url.IndexOf(rewrite.Match, StringComparison.OrdinalIgnoreCase) >= 0)
                        return url.Replace(rewrite.Match, rewrite.Replace, StringComparison.OrdinalIgnoreCase);
                    break;
            }
        }

PowerForge.Web/Services/WebAssetOptimizer.cs:973

        foreach (var ext in source)
        {
            if (string.IsNullOrWhiteSpace(ext))
                continue;
            var normalized = ext.Trim();
            if (!normalized.StartsWith(".", StringComparison.Ordinal))
                normalized = "." + normalized;
            set.Add(normalized);
        }

PowerForge.Web/Services/WebAssetOptimizer.cs:993

        foreach (var pattern in patterns)
        {
            if (string.IsNullOrWhiteSpace(pattern)) continue;
            var normalizedPattern = pattern.Replace('\\', '/');
            if (GlobMatch(normalizedPattern, normalized) || GlobMatch(normalizedPattern, withLeadingSlash))
                return true;
        }

PowerForge.Web/Services/WebAssetOptimizer.cs:1008

        foreach (var pattern in patterns)
        {
            if (string.IsNullOrWhiteSpace(pattern)) continue;
            var normalizedPattern = pattern.Replace('\\', '/');
            if (GlobMatch(normalizedPattern, normalized) || GlobMatch(normalizedPattern, withLeadingSlash))
                return true;
        }

PowerForge.Web/Services/WebSiteVerifier.cs:96

        foreach (var collection in spec.Collections)
        {
            if (collection is null) continue;
            var files = EnumerateCollectionFiles(plan.RootPath, collection.Input).ToArray();
            if (files.Length == 0)
            {
                warnings.Add($"Collection '{collection.Name}' has no files.");
                continue;
            }

            var leafBundleRoots = BuildLeafBundleRoots(files);
            foreach (var file in files)
            {
                if (IsUnderAnyRoot(file, leafBundleRoots) && !IsLeafBundleIndex(file))
                    continue;

                var markdown = File.ReadAllText(file);
                var (matter, body) = FrontMatterParser.Parse(markdown);
                var title = matter?.Title ?? FrontMatterParser.ExtractTitleFromMarkdown(body) ?? string.Empty;
                if (string.IsNullOrWhiteSpace(title))
                {
                    errors.Add($"Missing title in: {file}");
                }
                ValidateMarkdownHygiene(plan.RootPath, file, collection.Name, body, warnings);

                var collectionRoot = ResolveCollectionRootForFile(plan.RootPath, collection.Input, file);
                var relativePath = ResolveRelativePath(collectionRoot, file);
                var relativeDir = NormalizePath(Path.GetDirectoryName(relativePath) ?? string.Empty);
                var isSectionIndex = IsSectionIndex(file);
                var isBundleIndex = IsLeafBundleIndex(file);
                var slugPath = ResolveSlugPath(relativePath, relativeDir, matter?.Slug);
                if (isSectionIndex || isBundleIndex)
                    slugPath = ApplySlugOverride(relativeDir, matter?.Slug);
                if (string.IsNullOrWhiteSpace(slugPath))
                {
                    errors.Add($"Missing slug in: {file}");
                    continue;
                }

                var projectSlug = ResolveProjectSlug(plan, file);
                var baseOutput = ReplaceProjectPlaceholder(collection.Output, projectSlug);
                var route = BuildRoute(baseOutput, slugPath, spec.TrailingSlash);
                if (routes.TryGetValue(route, out var existing))
                {
                    errors.Add($"Duplicate route '{route}' from '{file}' and '{existing}'.");
                }
                else
                {
                    routes[route] = file;
                }

                if (!collectionRoutes.TryGetValue(collection.Name, out var list))
                {
                    list = new List<CollectionRoute>();
                    collectionRoutes[collection.Name] = list;
                }
                list.Add(new CollectionRoute(route, file, matter?.Draft ?? false));
            }
        }

PowerForge.Web/Services/WebSiteVerifier.cs:95

            foreach (var file in files)
            {
                if (IsUnderAnyRoot(file, leafBundleRoots) && !IsLeafBundleIndex(file))
                    continue;

                var markdown = File.ReadAllText(file);
                var (matter, body) = FrontMatterParser.Parse(markdown);
                var title = matter?.Title ?? FrontMatterParser.ExtractTitleFromMarkdown(body) ?? string.Empty;
                if (string.IsNullOrWhiteSpace(title))
                {
                    errors.Add($"Missing title in: {file}");
                }
                ValidateMarkdownHygiene(plan.RootPath, file, collection.Name, body, warnings);

                var collectionRoot = ResolveCollectionRootForFile(plan.RootPath, collection.Input, file);
                var relativePath = ResolveRelativePath(collectionRoot, file);
                var relativeDir = NormalizePath(Path.GetDirectoryName(relativePath) ?? string.Empty);
                var isSectionIndex = IsSectionIndex(file);
                var isBundleIndex = IsLeafBundleIndex(file);
                var slugPath = ResolveSlugPath(relativePath, relativeDir, matter?.Slug);
                if (isSectionIndex || isBundleIndex)
                    slugPath = ApplySlugOverride(relativeDir, matter?.Slug);
                if (string.IsNullOrWhiteSpace(slugPath))
                {
                    errors.Add($"Missing slug in: {file}");
                    continue;
                }

                var projectSlug = ResolveProjectSlug(plan, file);
                var baseOutput = ReplaceProjectPlaceholder(collection.Output, projectSlug);
                var route = BuildRoute(baseOutput, slugPath, spec.TrailingSlash);
                if (routes.TryGetValue(route, out var existing))
                {
                    errors.Add($"Duplicate route '{route}' from '{file}' and '{existing}'.");
                }
                else
                {
                    routes[route] = file;
                }

                if (!collectionRoutes.TryGetValue(collection.Name, out var list))
                {
                    list = new List<CollectionRoute>();
                    collectionRoutes[collection.Name] = list;
                }
                list.Add(new CollectionRoute(route, file, matter?.Draft ?? false));
            }

PowerForge.Web/Services/WebSiteVerifier.cs:240

        foreach (var root in roots)
        {
            if (string.IsNullOrWhiteSpace(root)) continue;
            if (filePath.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
                filePath.Equals(root, StringComparison.OrdinalIgnoreCase))
                return true;
        }

PowerForge.Web/Services/WebSiteVerifier.cs:273

        foreach (var project in plan.Projects ?? Array.Empty<WebProjectPlan>())
        {
            if (string.IsNullOrWhiteSpace(project.RootPath))
                continue;

            var projectDataRoot = Path.Combine(project.RootPath, "data");
            ValidateKnownDataFile(projectDataRoot, "faq.json", $"projects/{project.Slug}/data/faq.json", ValidateFaqJson, warnings);
            ValidateKnownDataFile(projectDataRoot, "showcase.json", $"projects/{project.Slug}/data/showcase.json", ValidateShowcaseJson, warnings);
            ValidateKnownDataFile(projectDataRoot, "pricing.json", $"projects/{project.Slug}/data/pricing.json", ValidatePricingJson, warnings);
            ValidateKnownDataFile(projectDataRoot, "benchmarks.json", $"projects/{project.Slug}/data/benchmarks.json", ValidateBenchmarksJson, warnings);
        }

PowerForge.Web/Services/WebSiteVerifier.cs:485

            foreach (var token in requiredTokens)
            {
                if (content.IndexOf(token, StringComparison.OrdinalIgnoreCase) >= 0)
                    continue;

                warnings.Add($"Layout '{layoutName}' is missing required token '{token}'. " +
                             "Per-page assets (e.g., Prism) may not load.");
            }

PowerForge.Web/Services/WebSiteVerifier.cs:676

            foreach (var key in manifest.Layouts.Keys)
            {
                if (!string.IsNullOrWhiteSpace(key))
                    names.Add(key);
            }

PowerForge.Web/Services/WebSiteVerifier.cs:835

        foreach (var collection in spec.Collections)
        {
            if (collection is null) continue;
            if (collection.UseToc == false) continue;

            if (!collectionRoutes.TryGetValue(collection.Name, out var routes) || routes.Count == 0)
                continue;

            var tocPath = ResolveTocPath(collection, plan.RootPath);
            if (string.IsNullOrWhiteSpace(tocPath) || !File.Exists(tocPath))
            {
                warnings.Add($"TOC is enabled for collection '{collection.Name}' but no toc.json/toc.yml was found.");
                continue;
            }

            var tocItems = LoadTocFromPath(tocPath);
            if (tocItems.Length == 0)
                continue;

            var tocUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            CollectTocUrls(tocItems, tocUrls, spec.TrailingSlash);

            if (tocUrls.Count == 0)
                continue;

            var outputRoot = string.IsNullOrWhiteSpace(collection.Output) ? "/" : collection.Output;
            var normalizedRoot = NormalizeRouteForCompare(outputRoot, spec.TrailingSlash);

            var routeSet = routes
                .Where(r => !r.Draft)
                .Select(r => r.Route)
                .ToHashSet(StringComparer.OrdinalIgnoreCase);

            var missing = routeSet
                .Where(r => !tocUrls.Contains(r))
                .ToList();

            if (missing.Count > 0)
            {
                var preview = string.Join(", ", missing.Take(5));
                var suffix = missing.Count > 5 ? " ..." : string.Empty;
                warnings.Add($"TOC for collection '{collection.Name}' is missing {missing.Count} page(s): {preview}{suffix}");
            }

            var extra = tocUrls
                .Where(u => u.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
                .Where(u => !routeSet.Contains(u))
                .ToList();

            if (extra.Count > 0)
            {
                var preview = string.Join(", ", extra.Take(5));
                var suffix = extra.Count > 5 ? " ..." : string.Empty;
                warnings.Add($"TOC for collection '{collection.Name}' contains {extra.Count} missing page(s): {preview}{suffix}");
            }
        }

PowerForge.Web/Services/WebSiteVerifier.cs:911

        foreach (var item in items ?? Array.Empty<TocItem>())
        {
            if (item is null || item.Hidden) continue;

            var url = item.Url ?? item.Href;
            var normalized = NormalizeTocUrl(url, slashMode);
            if (!string.IsNullOrWhiteSpace(normalized))
                urls.Add(normalized);

            if (item.Items is { Length: > 0 })
                CollectTocUrls(item.Items, urls, slashMode);
        }

PowerForge.Web/Services/WebSiteVerifier.cs:991

            foreach (var name in mapping.Bundles ?? Array.Empty<string>())
            {
                if (string.IsNullOrWhiteSpace(name))
                    continue;
                if (!seen.Add(name))
                    continue;
                if (bundleMap.TryGetValue(name, out var bundle))
                    selected.Add(bundle);
            }

PowerForge.Web/Services/WebSiteVerifier.cs:1040

        foreach (var css in assets.CriticalCss ?? Array.Empty<CriticalCssSpec>())
        {
            if (string.IsNullOrWhiteSpace(css.Path)) continue;
            if (IsExternalPath(css.Path)) continue;
            var fullPath = Path.IsPathRooted(css.Path)
                ? css.Path
                : Path.Combine(rootPath, css.Path);
            if (!File.Exists(fullPath))
                warnings.Add($"Missing {label} critical CSS: {css.Path}");
        }

PowerForge.Web/Services/WebSiteVerifier.cs:1121

        foreach (var preload in manifest.Assets.Preloads ?? Array.Empty<PreloadSpec>())
        {
            if (string.IsNullOrWhiteSpace(preload.Href))
                continue;
            if (!IsPortableRelativePath(preload.Href) &&
                !preload.Href.StartsWith("/", StringComparison.Ordinal) &&
                !IsExternalPath(preload.Href))
            {
                warnings.Add($"{label} preload href should be relative, absolute web URL, or root-relative URL: {preload.Href}");
            }

            if (preload.Href.StartsWith("/themes/", StringComparison.OrdinalIgnoreCase))
            {
                warnings.Add($"{label} preload href hard-codes '/themes/...'. Prefer relative paths in theme assets for portability: {preload.Href}");
            }
        }

PowerForge.Web/Services/WebSiteVerifier.cs:1131

        foreach (var critical in manifest.Assets.CriticalCss ?? Array.Empty<CriticalCssSpec>())
        {
            if (string.IsNullOrWhiteSpace(critical.Path))
                continue;
            if (!IsPortableRelativePath(critical.Path))
            {
                warnings.Add($"{label} criticalCss '{critical.Name}' should be a portable relative path: {critical.Path}");
            }
        }

PowerForge.Web/Services/WebSiteVerifier.cs:1375

        foreach (var prop in properties)
        {
            if (element.TryGetProperty(prop, out _))
                return true;
        }

PowerForge.Web/Services/WebSiteVerifier.cs:1491

        foreach (var project in plan.Projects)
        {
            if (string.IsNullOrWhiteSpace(project.ContentPath))
                continue;

            if (filePath.StartsWith(project.ContentPath, StringComparison.OrdinalIgnoreCase))
                return project.Slug;
        }

PowerForge.Web.Cli/Program.cs:1742

            foreach (var issue in result.Issues)
            {
                if (!string.IsNullOrWhiteSpace(issue.Key))
                    keys.Add(issue.Key);
            }

PowerForge.Web.Cli/Program.cs:1812

                foreach (var item in issueKeys.EnumerateArray())
                {
                    if (item.ValueKind != JsonValueKind.String) continue;
                    var value = item.GetString();
                    if (!string.IsNullOrWhiteSpace(value))
                        keys.Add(value);
                }

PowerForge.Web.Cli/Program.cs:1824

                foreach (var issue in issues.EnumerateArray())
                {
                    if (issue.ValueKind != JsonValueKind.Object) continue;
                    if (!TryGetPropertyIgnoreCase(issue, "key", out var keyElement) || keyElement.ValueKind != JsonValueKind.String) continue;
                    var value = keyElement.GetString();
                    if (!string.IsNullOrWhiteSpace(value))
                        keys.Add(value);
                }

PowerForge.Web.Cli/Program.cs:1861

            foreach (var property in element.EnumerateObject())
            {
                if (property.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase))
                {
                    value = property.Value;
                    return true;
                }
            }

PowerForge.Web.Cli/Program.cs:1965

                foreach (var dependencyIndex in definition.DependencyIndexes)
                {
                    if (!stepResultsByIndex.TryGetValue(dependencyIndex, out var dependencyResult) || !dependencyResult.Success)
                    {
                        throw new InvalidOperationException($"Step '{definition.Id}' dependency #{dependencyIndex} failed or was not executed.");
                    }
                }

PowerForge.Web.Cli/Program.cs:2735

        foreach (var step in steps)
        {
            if (step.DependsOn.Length == 0)
                continue;

            var resolved = new List<int>();
            foreach (var dependency in step.DependsOn)
            {
                if (string.IsNullOrWhiteSpace(dependency))
                    continue;

                if (int.TryParse(dependency, out var numeric))
                {
                    if (numeric <= 0 || numeric > steps.Count)
                        throw new InvalidOperationException($"Step '{step.Id}' has invalid dependsOn reference '{dependency}'.");
                    resolved.Add(numeric);
                    continue;
                }

                if (!aliases.TryGetValue(dependency, out var dependencyIndex))
                    throw new InvalidOperationException($"Step '{step.Id}' has unknown dependsOn reference '{dependency}'.");

                resolved.Add(dependencyIndex);
            }

            step.DependencyIndexes = resolved
                .Distinct()
                .OrderBy(value => value)
                .ToArray();

            if (step.DependencyIndexes.Any(value => value >= step.Index))
                throw new InvalidOperationException($"Step '{step.Id}' has dependsOn reference to current/future step.");
        }

PowerForge.Web.Cli/Program.cs:2726

            foreach (var dependency in step.DependsOn)
            {
                if (string.IsNullOrWhiteSpace(dependency))
                    continue;

                if (int.TryParse(dependency, out var numeric))
                {
                    if (numeric <= 0 || numeric > steps.Count)
                        throw new InvalidOperationException($"Step '{step.Id}' has invalid dependsOn reference '{dependency}'.");
                    resolved.Add(numeric);
                    continue;
                }

                if (!aliases.TryGetValue(dependency, out var dependencyIndex))
                    throw new InvalidOperationException($"Step '{step.Id}' has unknown dependsOn reference '{dependency}'.");

                resolved.Add(dependencyIndex);
            }

PowerForge.Web.Cli/Program.cs:2959

        foreach (var property in step.EnumerateObject())
        {
            if (!FingerprintPathKeys.Contains(property.Name))
                continue;

            if (property.Value.ValueKind == JsonValueKind.String)
            {
                var value = property.Value.GetString();
                if (string.IsNullOrWhiteSpace(value) || IsExternalUri(value))
                    continue;
                var resolved = ResolvePath(baseDir, value);
                if (!string.IsNullOrWhiteSpace(resolved))
                    yield return Path.GetFullPath(resolved);
                continue;
            }

            if (property.Value.ValueKind != JsonValueKind.Array)
                continue;

            foreach (var item in property.Value.EnumerateArray())
            {
                if (item.ValueKind != JsonValueKind.String)
                    continue;
                var value = item.GetString();
                if (string.IsNullOrWhiteSpace(value) || IsExternalUri(value))
                    continue;
                var resolved = ResolvePath(baseDir, value);
                if (!string.IsNullOrWhiteSpace(resolved))
                    yield return Path.GetFullPath(resolved);
            }
        }

PowerForge.Web.Cli/Program.cs:2958

            foreach (var item in property.Value.EnumerateArray())
            {
                if (item.ValueKind != JsonValueKind.String)
                    continue;
                var value = item.GetString();
                if (string.IsNullOrWhiteSpace(value) || IsExternalUri(value))
                    continue;
                var resolved = ResolvePath(baseDir, value);
                if (!string.IsNullOrWhiteSpace(resolved))
                    yield return Path.GetFullPath(resolved);
            }

PowerForge.Web.Cli/Program.cs:3113

        foreach (var output in outputs)
        {
            if (File.Exists(output))
                continue;
            if (Directory.Exists(output))
                continue;
            return false;
        }

PowerForge.Web.Cli/Program.cs:3216

        foreach (var item in value.EnumerateArray())
        {
            if (item.ValueKind != JsonValueKind.Object) continue;
            var path = GetString(item, "path") ?? GetString(item, "route") ?? GetString(item, "url");
            if (string.IsNullOrWhiteSpace(path)) continue;
            list.Add(new WebSitemapEntry
            {
                Path = path,
                ChangeFrequency = GetString(item, "changefreq") ?? GetString(item, "changeFrequency"),
                Priority = GetString(item, "priority"),
                LastModified = GetString(item, "lastmod") ?? GetString(item, "lastModified")
            });
        }

PowerForge.Web/Services/WebAssetOptimizer.cs:1066

            foreach (var path in map.Values)
            {
                var segments = path.TrimStart('/').Split('/');
                if (segments.Length > 1)
                    immutablePaths.Add($"/{segments[0]}/*");
            }

PowerForge.Web/Services/WebSiteVerifier.cs:686

            foreach (var collection in spec.Collections)
            {
                var layout = collection?.DefaultLayout;
                if (!string.IsNullOrWhiteSpace(layout))
                    names.Add(layout);
            }

PowerForge.Web/Services/WebSiteVerifier.cs:1330

  • These 'if' statements can be combined.
        if (TryGetObject(root, "hero", out var hero))
        {
            if (!HasAnyProperty(hero, "title"))
                warnings.Add($"Data file '{label}' hero missing 'title'.");
        }

PowerForge.Web/Services/WebSiteVerifier.cs:783

  • The expression 'A == false' can be simplified to '!A'.
            if (collection.UseToc == false) continue;

PowerForge.Web/Services/WebSiteVerifier.cs:840

  • The expression 'A == false' can be simplified to '!A'.
        if (collection.UseToc == false)

PowerForge.Web/Services/ThemeLoader.cs:199

  • Both branches of this 'if' statement write to the same variable - consider using '?' to express intent better.
                if (merged.TryGetValue(kvp.Key, out var existing) &&
                    existing is Dictionary<string, object?> existingMap &&
                    value is Dictionary<string, object?> childMap)
                {
                    merged[kvp.Key] = MergeTokens(existingMap, childMap);
                }
                else
                {
                    merged[kvp.Key] = value;
                }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +611 to +633
if (webpBytes.LongLength > 0 && webpBytes.LongLength < finalBytes &&
TryWriteVariant(siteRoot, relative, null, "webp", webpBytes, out var webpRelative, onUpdated))
{
generatedVariants.Add(new WebOptimizeImageVariantEntry
{
SourcePath = relative,
VariantPath = webpRelative,
Format = "webp",
Width = null,
Bytes = webpBytes.LongLength
});
if (options.ImagePreferNextGen && webpBytes.LongLength < preferredBytes)
{
preferredBytes = webpBytes.LongLength;
plan.PreferredRelativePath = webpRelative;
}
}
}

if (options.ImageGenerateAvif && TryEncodeVariant(image, null, MagickFormat.Avif, quality, out var avifBytes))
{
if (avifBytes.LongLength > 0 && avifBytes.LongLength < finalBytes &&
TryWriteVariant(siteRoot, relative, null, "avif", avifBytes, out var avifRelative, onUpdated))
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AVIF variant generation checks avifBytes.LongLength < finalBytes which compares against the source image size. However, if WebP was already generated and is smaller, the AVIF check should compare against preferredBytes instead to avoid generating AVIF variants that are smaller than the source but larger than WebP (and thus will never be preferred or used). This creates unnecessary disk usage. The same issue applies to the WebP check on line 611 if the order were reversed.

Copilot uses AI. Check for mistakes.
Comment on lines 690 to +692
result.ImageBytesAfter += finalBytes;
if (options.ImageMaxBytesPerFile > 0 && finalBytes > options.ImageMaxBytesPerFile)
budgetWarnings.Add($"Image '{relative}' exceeds max-bytes-per-file ({finalBytes} > {options.ImageMaxBytesPerFile}).");
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The image budget check uses finalBytes which represents the optimized source image size, but when ImagePreferNextGen is enabled and a next-gen variant (WebP/AVIF) is preferred, the budget should check against preferredBytes instead. This ensures the budget reflects the actual image bytes that will be served to users after HTML rewriting, not the intermediate source image bytes.

Suggested change
result.ImageBytesAfter += finalBytes;
if (options.ImageMaxBytesPerFile > 0 && finalBytes > options.ImageMaxBytesPerFile)
budgetWarnings.Add($"Image '{relative}' exceeds max-bytes-per-file ({finalBytes} > {options.ImageMaxBytesPerFile}).");
result.ImageBytesAfter += preferredBytes;
if (options.ImageMaxBytesPerFile > 0 && preferredBytes > options.ImageMaxBytesPerFile)
budgetWarnings.Add($"Image '{relative}' exceeds max-bytes-per-file ({preferredBytes} > {options.ImageMaxBytesPerFile}).");

Copilot uses AI. Check for mistakes.
continue;
if (!TryEncodeVariant(image, width, responsiveFormat, quality, out var responsiveBytes))
continue;
if (responsiveBytes.LongLength <= 0 || responsiveBytes.LongLength >= preferredBytes)
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition responsiveBytes.LongLength >= preferredBytes rejects responsive variants that are larger than or equal to the preferred (full-size) image. However, this prevents generating responsive variants when they have the same size as the preferred image. In practice, smaller responsive variants (e.g., 480w) that happen to compress to the same size as the full image could still be useful for bandwidth savings on mobile devices. Consider changing to strictly greater than (>) to allow equal-sized variants, or document why equal-sized variants should be rejected.

Suggested change
if (responsiveBytes.LongLength <= 0 || responsiveBytes.LongLength >= preferredBytes)
if (responsiveBytes.LongLength <= 0 || responsiveBytes.LongLength > preferredBytes)

Copilot uses AI. Check for mistakes.
{
attrsUpdated += $" srcset=\"{string.Join(", ", srcsetEntries)}\"";
if (!ImgSizesAttrRegex.IsMatch(attrsUpdated))
attrsUpdated += " sizes=\"100vw\"";
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default sizes="100vw" attribute assumes images span the full viewport width. While this is a safe default that works for many layouts, it may cause browsers to download larger images than necessary for images constrained by container width or CSS. Consider documenting that users should manually specify the sizes attribute for optimal responsive image behavior, or consider a more sophisticated default like sizes="(max-width: 768px) 100vw, 768px" for better mobile optimization.

Suggested change
attrsUpdated += " sizes=\"100vw\"";
attrsUpdated += " sizes=\"(max-width: 768px) 100vw, 768px\"";

Copilot uses AI. Check for mistakes.
Comment on lines +655 to +679
foreach (var width in responsiveWidths)
{
if (width >= imageWidth)
continue;
if (!TryEncodeVariant(image, width, responsiveFormat, quality, out var responsiveBytes))
continue;
if (responsiveBytes.LongLength <= 0 || responsiveBytes.LongLength >= preferredBytes)
continue;
if (!TryWriteVariant(siteRoot, plan.PreferredRelativePath, width, responsiveExtension, responsiveBytes, out var variantRelative, onUpdated))
continue;

plan.ResponsiveVariants.Add(new ImageVariantPlan
{
RelativePath = variantRelative,
Width = width
});
generatedVariants.Add(new WebOptimizeImageVariantEntry
{
SourcePath = relative,
VariantPath = variantRelative,
Format = responsiveExtension,
Width = width,
Bytes = responsiveBytes.LongLength
});
}
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
Comment on lines +3157 to +3166
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
continue;
foreach (var token in value.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
{
if (int.TryParse(token.Trim(), out var parsed) && parsed > 0)
list.Add(parsed);
}
}
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
Comment on lines +3161 to +3165
foreach (var token in value.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
{
if (int.TryParse(token.Trim(), out var parsed) && parsed > 0)
list.Add(parsed);
}
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
Comment on lines +609 to +647
if (options.ImageGenerateWebp && TryEncodeVariant(image, null, MagickFormat.WebP, quality, out var webpBytes))
{
if (webpBytes.LongLength > 0 && webpBytes.LongLength < finalBytes &&
TryWriteVariant(siteRoot, relative, null, "webp", webpBytes, out var webpRelative, onUpdated))
{
generatedVariants.Add(new WebOptimizeImageVariantEntry
{
SourcePath = relative,
VariantPath = webpRelative,
Format = "webp",
Width = null,
Bytes = webpBytes.LongLength
});
if (options.ImagePreferNextGen && webpBytes.LongLength < preferredBytes)
{
preferredBytes = webpBytes.LongLength;
plan.PreferredRelativePath = webpRelative;
}
}
}

if (options.ImageGenerateAvif && TryEncodeVariant(image, null, MagickFormat.Avif, quality, out var avifBytes))
{
if (avifBytes.LongLength > 0 && avifBytes.LongLength < finalBytes &&
TryWriteVariant(siteRoot, relative, null, "avif", avifBytes, out var avifRelative, onUpdated))
{
generatedVariants.Add(new WebOptimizeImageVariantEntry
{
SourcePath = relative,
VariantPath = avifRelative,
Format = "avif",
Width = null,
Bytes = avifBytes.LongLength
});
if (options.ImagePreferNextGen && avifBytes.LongLength < preferredBytes)
{
preferredBytes = avifBytes.LongLength;
plan.PreferredRelativePath = avifRelative;
}
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 'if' statements can be combined.

Suggested change
if (options.ImageGenerateWebp && TryEncodeVariant(image, null, MagickFormat.WebP, quality, out var webpBytes))
{
if (webpBytes.LongLength > 0 && webpBytes.LongLength < finalBytes &&
TryWriteVariant(siteRoot, relative, null, "webp", webpBytes, out var webpRelative, onUpdated))
{
generatedVariants.Add(new WebOptimizeImageVariantEntry
{
SourcePath = relative,
VariantPath = webpRelative,
Format = "webp",
Width = null,
Bytes = webpBytes.LongLength
});
if (options.ImagePreferNextGen && webpBytes.LongLength < preferredBytes)
{
preferredBytes = webpBytes.LongLength;
plan.PreferredRelativePath = webpRelative;
}
}
}
if (options.ImageGenerateAvif && TryEncodeVariant(image, null, MagickFormat.Avif, quality, out var avifBytes))
{
if (avifBytes.LongLength > 0 && avifBytes.LongLength < finalBytes &&
TryWriteVariant(siteRoot, relative, null, "avif", avifBytes, out var avifRelative, onUpdated))
{
generatedVariants.Add(new WebOptimizeImageVariantEntry
{
SourcePath = relative,
VariantPath = avifRelative,
Format = "avif",
Width = null,
Bytes = avifBytes.LongLength
});
if (options.ImagePreferNextGen && avifBytes.LongLength < preferredBytes)
{
preferredBytes = avifBytes.LongLength;
plan.PreferredRelativePath = avifRelative;
}
if (options.ImageGenerateWebp &&
TryEncodeVariant(image, null, MagickFormat.WebP, quality, out var webpBytes) &&
webpBytes.LongLength > 0 &&
webpBytes.LongLength < finalBytes &&
TryWriteVariant(siteRoot, relative, null, "webp", webpBytes, out var webpRelative, onUpdated))
{
generatedVariants.Add(new WebOptimizeImageVariantEntry
{
SourcePath = relative,
VariantPath = webpRelative,
Format = "webp",
Width = null,
Bytes = webpBytes.LongLength
});
if (options.ImagePreferNextGen && webpBytes.LongLength < preferredBytes)
{
preferredBytes = webpBytes.LongLength;
plan.PreferredRelativePath = webpRelative;
}
}
if (options.ImageGenerateAvif &&
TryEncodeVariant(image, null, MagickFormat.Avif, quality, out var avifBytes) &&
avifBytes.LongLength > 0 &&
avifBytes.LongLength < finalBytes &&
TryWriteVariant(siteRoot, relative, null, "avif", avifBytes, out var avifRelative, onUpdated))
{
generatedVariants.Add(new WebOptimizeImageVariantEntry
{
SourcePath = relative,
VariantPath = avifRelative,
Format = "avif",
Width = null,
Bytes = avifBytes.LongLength
});
if (options.ImagePreferNextGen && avifBytes.LongLength < preferredBytes)
{
preferredBytes = avifBytes.LongLength;
plan.PreferredRelativePath = avifRelative;

Copilot uses AI. Check for mistakes.
Comment on lines +609 to +647
if (options.ImageGenerateWebp && TryEncodeVariant(image, null, MagickFormat.WebP, quality, out var webpBytes))
{
if (webpBytes.LongLength > 0 && webpBytes.LongLength < finalBytes &&
TryWriteVariant(siteRoot, relative, null, "webp", webpBytes, out var webpRelative, onUpdated))
{
generatedVariants.Add(new WebOptimizeImageVariantEntry
{
SourcePath = relative,
VariantPath = webpRelative,
Format = "webp",
Width = null,
Bytes = webpBytes.LongLength
});
if (options.ImagePreferNextGen && webpBytes.LongLength < preferredBytes)
{
preferredBytes = webpBytes.LongLength;
plan.PreferredRelativePath = webpRelative;
}
}
}

if (options.ImageGenerateAvif && TryEncodeVariant(image, null, MagickFormat.Avif, quality, out var avifBytes))
{
if (avifBytes.LongLength > 0 && avifBytes.LongLength < finalBytes &&
TryWriteVariant(siteRoot, relative, null, "avif", avifBytes, out var avifRelative, onUpdated))
{
generatedVariants.Add(new WebOptimizeImageVariantEntry
{
SourcePath = relative,
VariantPath = avifRelative,
Format = "avif",
Width = null,
Bytes = avifBytes.LongLength
});
if (options.ImagePreferNextGen && avifBytes.LongLength < preferredBytes)
{
preferredBytes = avifBytes.LongLength;
plan.PreferredRelativePath = avifRelative;
}
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 'if' statements can be combined.

Suggested change
if (options.ImageGenerateWebp && TryEncodeVariant(image, null, MagickFormat.WebP, quality, out var webpBytes))
{
if (webpBytes.LongLength > 0 && webpBytes.LongLength < finalBytes &&
TryWriteVariant(siteRoot, relative, null, "webp", webpBytes, out var webpRelative, onUpdated))
{
generatedVariants.Add(new WebOptimizeImageVariantEntry
{
SourcePath = relative,
VariantPath = webpRelative,
Format = "webp",
Width = null,
Bytes = webpBytes.LongLength
});
if (options.ImagePreferNextGen && webpBytes.LongLength < preferredBytes)
{
preferredBytes = webpBytes.LongLength;
plan.PreferredRelativePath = webpRelative;
}
}
}
if (options.ImageGenerateAvif && TryEncodeVariant(image, null, MagickFormat.Avif, quality, out var avifBytes))
{
if (avifBytes.LongLength > 0 && avifBytes.LongLength < finalBytes &&
TryWriteVariant(siteRoot, relative, null, "avif", avifBytes, out var avifRelative, onUpdated))
{
generatedVariants.Add(new WebOptimizeImageVariantEntry
{
SourcePath = relative,
VariantPath = avifRelative,
Format = "avif",
Width = null,
Bytes = avifBytes.LongLength
});
if (options.ImagePreferNextGen && avifBytes.LongLength < preferredBytes)
{
preferredBytes = avifBytes.LongLength;
plan.PreferredRelativePath = avifRelative;
}
if (options.ImageGenerateWebp &&
TryEncodeVariant(image, null, MagickFormat.WebP, quality, out var webpBytes) &&
webpBytes.LongLength > 0 &&
webpBytes.LongLength < finalBytes &&
TryWriteVariant(siteRoot, relative, null, "webp", webpBytes, out var webpRelative, onUpdated))
{
generatedVariants.Add(new WebOptimizeImageVariantEntry
{
SourcePath = relative,
VariantPath = webpRelative,
Format = "webp",
Width = null,
Bytes = webpBytes.LongLength
});
if (options.ImagePreferNextGen && webpBytes.LongLength < preferredBytes)
{
preferredBytes = webpBytes.LongLength;
plan.PreferredRelativePath = webpRelative;
}
}
if (options.ImageGenerateAvif &&
TryEncodeVariant(image, null, MagickFormat.Avif, quality, out var avifBytes) &&
avifBytes.LongLength > 0 &&
avifBytes.LongLength < finalBytes &&
TryWriteVariant(siteRoot, relative, null, "avif", avifBytes, out var avifRelative, onUpdated))
{
generatedVariants.Add(new WebOptimizeImageVariantEntry
{
SourcePath = relative,
VariantPath = avifRelative,
Format = "avif",
Width = null,
Bytes = avifBytes.LongLength
});
if (options.ImagePreferNextGen && avifBytes.LongLength < preferredBytes)
{
preferredBytes = avifBytes.LongLength;
plan.PreferredRelativePath = avifRelative;

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants