| | 1 | | // Copyright DotNet API Diff Project Contributors - SPDX Identifier: MIT |
| | 2 | | using DotNetApiDiff.Interfaces; |
| | 3 | | using DotNetApiDiff.Models; |
| | 4 | | using DotNetApiDiff.Models.Configuration; |
| | 5 | | using Scriban; |
| | 6 | | using Scriban.Runtime; |
| | 7 | | using System.Linq; |
| | 8 | |
|
| | 9 | | namespace DotNetApiDiff.Reporting; |
| | 10 | |
|
| | 11 | | /// <summary> |
| | 12 | | /// Formatter for HTML output with rich formatting and interactive features using Scriban templates |
| | 13 | | /// </summary> |
| | 14 | | public class HtmlFormatterScriban : IReportFormatter |
| | 15 | | { |
| | 16 | | private readonly Template _mainTemplate; |
| | 17 | |
|
| 8 | 18 | | public HtmlFormatterScriban() |
| 8 | 19 | | { |
| | 20 | | // Initialize main template from embedded resources |
| | 21 | | try |
| 8 | 22 | | { |
| 8 | 23 | | _mainTemplate = Template.Parse(EmbeddedTemplateLoader.LoadTemplate("main-layout.scriban")); |
| 8 | 24 | | } |
| 0 | 25 | | catch (Exception ex) |
| 0 | 26 | | { |
| 0 | 27 | | throw new InvalidOperationException("Failed to initialize Scriban HTML formatter templates. Ensure template |
| | 28 | | } |
| 8 | 29 | | } |
| | 30 | |
|
| | 31 | | /// <summary> |
| | 32 | | /// Formats a comparison result as HTML using Scriban templates |
| | 33 | | /// </summary> |
| | 34 | | /// <param name="result">The comparison result to format</param> |
| | 35 | | /// <returns>Formatted HTML output as a string</returns> |
| | 36 | | public string Format(ComparisonResult result) |
| 6 | 37 | | { |
| | 38 | | // Create the template context with custom functions |
| 6 | 39 | | var context = new TemplateContext(); |
| 6 | 40 | | var scriptObject = new ScriptObject(); |
| | 41 | |
|
| | 42 | | // Add custom functions |
| 6 | 43 | | scriptObject.Import("format_boolean", new Func<bool, string>(FormatBooleanValue)); |
| 6 | 44 | | scriptObject.Import("render_change_group", new Func<object, string>(RenderChangeGroup)); |
| | 45 | |
|
| | 46 | | // Prepare data for the main template |
| 6 | 47 | | var resultData = PrepareResultData(result); |
| 6 | 48 | | var changeSections = PrepareChangeSections(result); |
| 6 | 49 | | var cssStyles = GetCssStyles(); |
| 6 | 50 | | var javascriptCode = GetJavaScriptCode(); |
| | 51 | |
|
| | 52 | | // Add template data to script object |
| 6 | 53 | | scriptObject.SetValue("result", resultData, true); |
| 6 | 54 | | scriptObject.SetValue("change_sections", changeSections, true); |
| 6 | 55 | | scriptObject.SetValue("css_styles", cssStyles, true); |
| 6 | 56 | | scriptObject.SetValue("javascript_code", javascriptCode, true); |
| 6 | 57 | | scriptObject.SetValue("config", PrepareConfigData(result.Configuration), true); |
| | 58 | |
|
| 6 | 59 | | context.PushGlobal(scriptObject); |
| | 60 | |
|
| | 61 | | // Set up template loader for includes |
| 6 | 62 | | context.TemplateLoader = new CustomTemplateLoader(); |
| | 63 | |
|
| 6 | 64 | | return _mainTemplate.Render(context); |
| 6 | 65 | | } |
| | 66 | |
|
| | 67 | | private static string HtmlEscape(string? input) |
| 10 | 68 | | { |
| 10 | 69 | | if (string.IsNullOrEmpty(input)) |
| 7 | 70 | | { |
| 7 | 71 | | return input ?? string.Empty; |
| | 72 | | } |
| | 73 | |
|
| 3 | 74 | | return input |
| 3 | 75 | | .Replace("&", "&") |
| 3 | 76 | | .Replace("<", "<") |
| 3 | 77 | | .Replace(">", ">") |
| 3 | 78 | | .Replace("\"", """) |
| 3 | 79 | | .Replace("'", "'"); |
| 10 | 80 | | } |
| | 81 | |
|
| | 82 | | private object PrepareConfigData(ComparisonConfiguration config) |
| 12 | 83 | | { |
| 12 | 84 | | var namespaceMappings = config?.Mappings?.NamespaceMappings ?? new Dictionary<string, List<string>>(); |
| | 85 | |
|
| | 86 | | // Convert Dictionary to array of objects with key/value properties for Scriban |
| 12 | 87 | | var namespaceMappingsArray = namespaceMappings.Select(kvp => new { key = kvp.Key, value = kvp.Value }).ToList(); |
| 12 | 88 | | var typeMappingsArray = (config?.Mappings?.TypeMappings ?? new Dictionary<string, string>()).Select(kvp => new { |
| | 89 | |
|
| 12 | 90 | | var mappingsResult = new |
| 12 | 91 | | { |
| 12 | 92 | | namespace_mappings = namespaceMappingsArray, |
| 12 | 93 | | type_mappings = typeMappingsArray, |
| 12 | 94 | | auto_map_same_name_types = config?.Mappings?.AutoMapSameNameTypes ?? false, |
| 12 | 95 | | ignore_case = config?.Mappings?.IgnoreCase ?? false |
| 12 | 96 | | }; |
| | 97 | |
|
| 12 | 98 | | return new |
| 12 | 99 | | { |
| 12 | 100 | | filters = new |
| 12 | 101 | | { |
| 12 | 102 | | include_internals = config?.Filters?.IncludeInternals ?? false, |
| 12 | 103 | | include_compiler_generated = config?.Filters?.IncludeCompilerGenerated ?? false, |
| 12 | 104 | | include_namespaces = config?.Filters?.IncludeNamespaces?.ToList() ?? new List<string>(), |
| 12 | 105 | | exclude_namespaces = config?.Filters?.ExcludeNamespaces?.ToList() ?? new List<string>(), |
| 12 | 106 | | include_types = config?.Filters?.IncludeTypes?.ToList() ?? new List<string>(), |
| 12 | 107 | | exclude_types = config?.Filters?.ExcludeTypes?.ToList() ?? new List<string>() |
| 12 | 108 | | }, |
| 12 | 109 | | mappings = mappingsResult, |
| 12 | 110 | | exclusions = new |
| 12 | 111 | | { |
| 12 | 112 | | excluded_types = config?.Exclusions?.ExcludedTypes?.ToList() ?? new List<string>(), |
| 12 | 113 | | excluded_members = config?.Exclusions?.ExcludedMembers?.ToList() ?? new List<string>(), |
| 12 | 114 | | excluded_type_patterns = config?.Exclusions?.ExcludedTypePatterns?.ToList() ?? new List<string>(), |
| 12 | 115 | | excluded_member_patterns = config?.Exclusions?.ExcludedMemberPatterns?.ToList() ?? new List<string>(), |
| 12 | 116 | | exclude_compiler_generated = config?.Exclusions?.ExcludeCompilerGenerated ?? false, |
| 12 | 117 | | exclude_obsolete = config?.Exclusions?.ExcludeObsolete ?? false |
| 12 | 118 | | }, |
| 12 | 119 | | breaking_change_rules = new |
| 12 | 120 | | { |
| 12 | 121 | | treat_type_removal_as_breaking = config?.BreakingChangeRules?.TreatTypeRemovalAsBreaking ?? true, |
| 12 | 122 | | treat_member_removal_as_breaking = config?.BreakingChangeRules?.TreatMemberRemovalAsBreaking ?? true, |
| 12 | 123 | | treat_signature_change_as_breaking = config?.BreakingChangeRules?.TreatSignatureChangeAsBreaking ?? true |
| 12 | 124 | | treat_reduced_accessibility_as_breaking = config?.BreakingChangeRules?.TreatReducedAccessibilityAsBreaki |
| 12 | 125 | | treat_added_type_as_breaking = config?.BreakingChangeRules?.TreatAddedTypeAsBreaking ?? false, |
| 12 | 126 | | treat_added_member_as_breaking = config?.BreakingChangeRules?.TreatAddedMemberAsBreaking ?? false, |
| 12 | 127 | | treat_added_interface_as_breaking = config?.BreakingChangeRules?.TreatAddedInterfaceAsBreaking ?? true, |
| 12 | 128 | | treat_removed_interface_as_breaking = config?.BreakingChangeRules?.TreatRemovedInterfaceAsBreaking ?? tr |
| 12 | 129 | | treat_parameter_name_change_as_breaking = config?.BreakingChangeRules?.TreatParameterNameChangeAsBreakin |
| 12 | 130 | | treat_added_optional_parameter_as_breaking = config?.BreakingChangeRules?.TreatAddedOptionalParameterAsB |
| 12 | 131 | | }, |
| 12 | 132 | | output_format = config?.OutputFormat.ToString() ?? "Console", |
| 12 | 133 | | output_path = config?.OutputPath ?? string.Empty, |
| 12 | 134 | | fail_on_breaking_changes = config?.FailOnBreakingChanges ?? false |
| 12 | 135 | | }; |
| 12 | 136 | | } |
| | 137 | |
|
| | 138 | | private object PrepareResultData(ComparisonResult result) |
| 6 | 139 | | { |
| 6 | 140 | | return new |
| 6 | 141 | | { |
| 6 | 142 | | comparison_timestamp = result.ComparisonTimestamp, |
| 6 | 143 | | old_assembly_name = Path.GetFileName(result.OldAssemblyPath), |
| 6 | 144 | | old_assembly_path = result.OldAssemblyPath, |
| 6 | 145 | | new_assembly_name = Path.GetFileName(result.NewAssemblyPath), |
| 6 | 146 | | new_assembly_path = result.NewAssemblyPath, |
| 6 | 147 | | total_differences = result.TotalDifferences, |
| 6 | 148 | | has_breaking_changes = result.HasBreakingChanges, |
| 5 | 149 | | breaking_changes_count = result.Differences.Count(d => d.IsBreakingChange), |
| 6 | 150 | | summary = new |
| 6 | 151 | | { |
| 6 | 152 | | added_count = result.Summary.AddedCount, |
| 6 | 153 | | removed_count = result.Summary.RemovedCount, |
| 6 | 154 | | modified_count = result.Summary.ModifiedCount, |
| 6 | 155 | | breaking_changes_count = result.Summary.BreakingChangesCount |
| 6 | 156 | | }, |
| 6 | 157 | | configuration = PrepareConfigData(result.Configuration), |
| 5 | 158 | | breaking_changes = PrepareBreakingChangesData(result.Differences.Where(d => d.IsBreakingChange)) |
| 6 | 159 | | }; |
| 6 | 160 | | } |
| | 161 | |
|
| | 162 | | private object[] PrepareChangeSections(ComparisonResult result) |
| 6 | 163 | | { |
| 6 | 164 | | var sections = new List<object>(); |
| | 165 | |
|
| 11 | 166 | | var addedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Added).ToList(); |
| 6 | 167 | | if (addedItems.Any()) |
| 2 | 168 | | { |
| 2 | 169 | | sections.Add(new |
| 2 | 170 | | { |
| 2 | 171 | | icon = "➕", |
| 2 | 172 | | title = "Added Items", |
| 2 | 173 | | count = addedItems.Count, |
| 2 | 174 | | change_type = "added", |
| 2 | 175 | | grouped_changes = GroupChanges(addedItems) |
| 2 | 176 | | }); |
| 2 | 177 | | } |
| | 178 | |
|
| 11 | 179 | | var removedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Removed).ToList(); |
| 6 | 180 | | if (removedItems.Any()) |
| 2 | 181 | | { |
| 2 | 182 | | sections.Add(new |
| 2 | 183 | | { |
| 2 | 184 | | icon = "➖", |
| 2 | 185 | | title = "Removed Items", |
| 2 | 186 | | count = removedItems.Count, |
| 2 | 187 | | change_type = "removed", |
| 2 | 188 | | grouped_changes = GroupChanges(removedItems) |
| 2 | 189 | | }); |
| 2 | 190 | | } |
| | 191 | |
|
| 11 | 192 | | var modifiedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Modified).ToList(); |
| 6 | 193 | | if (modifiedItems.Any()) |
| 1 | 194 | | { |
| 1 | 195 | | sections.Add(new |
| 1 | 196 | | { |
| 1 | 197 | | icon = "🔄", |
| 1 | 198 | | title = "Modified Items", |
| 1 | 199 | | count = modifiedItems.Count, |
| 1 | 200 | | change_type = "modified", |
| 1 | 201 | | grouped_changes = GroupChanges(modifiedItems) |
| 1 | 202 | | }); |
| 1 | 203 | | } |
| | 204 | |
|
| 11 | 205 | | var movedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Moved).ToList(); |
| 6 | 206 | | if (movedItems.Any()) |
| 0 | 207 | | { |
| 0 | 208 | | sections.Add(new |
| 0 | 209 | | { |
| 0 | 210 | | icon = "📦", |
| 0 | 211 | | title = "Moved Items", |
| 0 | 212 | | count = movedItems.Count, |
| 0 | 213 | | change_type = "moved", |
| 0 | 214 | | grouped_changes = GroupChanges(movedItems) |
| 0 | 215 | | }); |
| 0 | 216 | | } |
| | 217 | |
|
| 11 | 218 | | var excludedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Excluded).ToList(); |
| 6 | 219 | | if (excludedItems.Any()) |
| 0 | 220 | | { |
| 0 | 221 | | sections.Add(new |
| 0 | 222 | | { |
| 0 | 223 | | icon = "🚫", |
| 0 | 224 | | title = "Excluded Items", |
| 0 | 225 | | count = excludedItems.Count, |
| 0 | 226 | | change_type = "excluded", |
| 0 | 227 | | description = "The following items were intentionally excluded from the comparison:", |
| 0 | 228 | | grouped_changes = GroupChanges(excludedItems) |
| 0 | 229 | | }); |
| 0 | 230 | | } |
| | 231 | |
|
| 6 | 232 | | return sections.ToArray(); |
| 6 | 233 | | } |
| | 234 | |
|
| | 235 | | private object[] GroupChanges(List<ApiDifference> changes) |
| 5 | 236 | | { |
| 10 | 237 | | return changes.GroupBy(c => c.ElementType) |
| 5 | 238 | | .OrderBy(g => g.Key) |
| 10 | 239 | | .Select(g => new |
| 10 | 240 | | { |
| 10 | 241 | | key = g.Key, |
| 10 | 242 | | count = g.Count(), |
| 10 | 243 | | changes = g.OrderBy(c => c.ElementName).Select(c => new |
| 5 | 244 | | { |
| 5 | 245 | | element_name = c.ElementName, |
| 5 | 246 | | description = c.Description, |
| 5 | 247 | | is_breaking_change = c.IsBreakingChange, |
| 5 | 248 | | has_signatures = !string.IsNullOrEmpty(c.OldSignature) || !string.IsNullOrEmpty(c.NewSignature), |
| 5 | 249 | | old_signature = HtmlEscape(c.OldSignature), |
| 5 | 250 | | new_signature = HtmlEscape(c.NewSignature), |
| 5 | 251 | | details_id = $"details-{Guid.NewGuid():N}" |
| 5 | 252 | | }).ToArray() |
| 10 | 253 | | }).ToArray(); |
| 5 | 254 | | } |
| | 255 | |
|
| | 256 | | private object[] PrepareBreakingChangesData(IEnumerable<ApiDifference> breakingChanges) |
| 6 | 257 | | { |
| 7 | 258 | | return breakingChanges.OrderBy(d => d.Severity) |
| 1 | 259 | | .ThenBy(d => d.ElementType) |
| 1 | 260 | | .ThenBy(d => d.ElementName) |
| 7 | 261 | | .Select(change => new |
| 7 | 262 | | { |
| 7 | 263 | | severity = change.Severity.ToString(), |
| 7 | 264 | | severity_class = change.Severity.ToString().ToLower(), |
| 7 | 265 | | element_type = change.ElementType, |
| 7 | 266 | | element_name = change.ElementName, |
| 7 | 267 | | description = change.Description |
| 7 | 268 | | }).ToArray(); |
| 6 | 269 | | } |
| | 270 | |
|
| | 271 | | private string FormatBooleanValue(bool value) |
| 102 | 272 | | { |
| 102 | 273 | | return value ? "<span class=\"boolean-true\">✓ True</span>" : "<span class=\"boolean-false\">✗ False</span>"; |
| 102 | 274 | | } |
| | 275 | |
|
| | 276 | | private string RenderChangeGroup(object sectionData) |
| 5 | 277 | | { |
| | 278 | | try |
| 5 | 279 | | { |
| | 280 | | // Load and parse the change-group template |
| 5 | 281 | | var templateContent = EmbeddedTemplateLoader.LoadTemplate("change-group.scriban"); |
| 5 | 282 | | var template = Template.Parse(templateContent); |
| | 283 | |
|
| | 284 | | // Create a new context for the template with the section data as root |
| 5 | 285 | | var context = new TemplateContext(); |
| 5 | 286 | | var scriptObject = new ScriptObject(); |
| | 287 | |
|
| | 288 | | // Add the section data properties to the script object |
| 5 | 289 | | if (sectionData != null) |
| 5 | 290 | | { |
| 5 | 291 | | var sectionType = sectionData.GetType(); |
| 65 | 292 | | foreach (var property in sectionType.GetProperties()) |
| 25 | 293 | | { |
| 25 | 294 | | var value = property.GetValue(sectionData); |
| 25 | 295 | | scriptObject.SetValue(property.Name.ToLowerInvariant(), value, true); |
| 25 | 296 | | } |
| 5 | 297 | | } |
| | 298 | |
|
| 5 | 299 | | context.PushGlobal(scriptObject); |
| | 300 | |
|
| 5 | 301 | | return template.Render(context); |
| | 302 | | } |
| 0 | 303 | | catch (Exception ex) |
| 0 | 304 | | { |
| 0 | 305 | | return $"<!-- Error rendering change group: {ex.Message} -->"; |
| | 306 | | } |
| 5 | 307 | | } |
| | 308 | |
|
| | 309 | | private string GetCssStyles() |
| 6 | 310 | | { |
| | 311 | | try |
| 6 | 312 | | { |
| 6 | 313 | | return EmbeddedTemplateLoader.LoadStyles(); |
| | 314 | | } |
| 0 | 315 | | catch (Exception ex) |
| 0 | 316 | | { |
| 0 | 317 | | System.Diagnostics.Debug.WriteLine($"Warning: Could not load CSS styles, using fallback: {ex.Message}"); |
| 0 | 318 | | return GetFallbackStyles(); |
| | 319 | | } |
| 6 | 320 | | } |
| | 321 | |
|
| | 322 | | private string GetJavaScriptCode() |
| 6 | 323 | | { |
| | 324 | | try |
| 6 | 325 | | { |
| 6 | 326 | | return EmbeddedTemplateLoader.LoadScripts(); |
| | 327 | | } |
| 0 | 328 | | catch (Exception ex) |
| 0 | 329 | | { |
| 0 | 330 | | System.Diagnostics.Debug.WriteLine($"Warning: Could not load JavaScript, using fallback: {ex.Message}"); |
| 0 | 331 | | return GetFallbackJavaScript(); |
| | 332 | | } |
| 6 | 333 | | } |
| | 334 | |
|
| | 335 | | private string GetFallbackStyles() |
| 0 | 336 | | { |
| 0 | 337 | | return "body { font-family: Arial, sans-serif; margin: 20px; }"; |
| 0 | 338 | | } |
| | 339 | |
|
| | 340 | | private string GetFallbackJavaScript() |
| 0 | 341 | | { |
| 0 | 342 | | return "// Fallback JavaScript"; |
| 0 | 343 | | } |
| | 344 | | } |