| | | 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 = GroupChangesByType(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 = GroupChangesByType(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 = GroupChangesByType(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 = GroupChangesByType(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 = GroupChangesByType(excludedItems) |
| | 0 | 229 | | }); |
| | 0 | 230 | | } |
| | | 231 | | |
| | 6 | 232 | | return sections.ToArray(); |
| | 6 | 233 | | } |
| | | 234 | | |
| | | 235 | | private object[] GroupChanges(List<ApiDifference> changes) |
| | 0 | 236 | | { |
| | 0 | 237 | | return changes.GroupBy(c => c.ElementType) |
| | 0 | 238 | | .OrderBy(g => g.Key) |
| | 0 | 239 | | .Select(g => new |
| | 0 | 240 | | { |
| | 0 | 241 | | key = g.Key, |
| | 0 | 242 | | count = g.Count(), |
| | 0 | 243 | | changes = g.OrderBy(c => c.ElementName).Select(c => new |
| | 0 | 244 | | { |
| | 0 | 245 | | element_name = c.ElementName, |
| | 0 | 246 | | description = c.Description, |
| | 0 | 247 | | is_breaking_change = c.IsBreakingChange, |
| | 0 | 248 | | has_signatures = !string.IsNullOrEmpty(c.OldSignature) || !string.IsNullOrEmpty(c.NewSignature), |
| | 0 | 249 | | old_signature = HtmlEscape(c.OldSignature), |
| | 0 | 250 | | new_signature = HtmlEscape(c.NewSignature), |
| | 0 | 251 | | details_id = $"details-{Guid.NewGuid():N}" |
| | 0 | 252 | | }).ToArray() |
| | 0 | 253 | | }).ToArray(); |
| | 0 | 254 | | } |
| | | 255 | | |
| | | 256 | | private object[] GroupChangesByType(List<ApiDifference> changes) |
| | 5 | 257 | | { |
| | | 258 | | // Group changes by the containing type first |
| | 5 | 259 | | return changes |
| | 5 | 260 | | .GroupBy(d => ExtractContainingType(d.ElementName, d.ElementType)) |
| | 5 | 261 | | .OrderBy(g => g.Key) |
| | 10 | 262 | | .Select(typeGroup => new |
| | 10 | 263 | | { |
| | 10 | 264 | | key = GetTypeDisplayName(typeGroup.Key), |
| | 10 | 265 | | full_type_name = typeGroup.Key, |
| | 10 | 266 | | count = typeGroup.Count(), |
| | 5 | 267 | | breaking_changes_count = typeGroup.Count(c => c.IsBreakingChange), |
| | 5 | 268 | | has_breaking_changes = typeGroup.Any(c => c.IsBreakingChange), |
| | 10 | 269 | | changes = typeGroup.OrderBy(c => c.ElementName).Select(c => new |
| | 5 | 270 | | { |
| | 5 | 271 | | element_name = GetMemberDisplayName(c.ElementName, c.ElementType), |
| | 5 | 272 | | full_element_name = c.ElementName, |
| | 5 | 273 | | element_type = c.ElementType.ToString(), |
| | 5 | 274 | | description = c.Description, |
| | 5 | 275 | | is_breaking_change = c.IsBreakingChange, |
| | 5 | 276 | | has_signatures = !string.IsNullOrEmpty(c.OldSignature) || !string.IsNullOrEmpty(c.NewSignature), |
| | 5 | 277 | | old_signature = HtmlEscape(c.OldSignature), |
| | 5 | 278 | | new_signature = HtmlEscape(c.NewSignature), |
| | 5 | 279 | | details_id = $"details-{Guid.NewGuid():N}", |
| | 5 | 280 | | severity = c.Severity.ToString().ToLower() |
| | 5 | 281 | | }).ToArray() |
| | 10 | 282 | | }).ToArray(); |
| | 5 | 283 | | } |
| | | 284 | | |
| | | 285 | | private string ExtractContainingType(string elementName, ApiElementType elementType) |
| | 5 | 286 | | { |
| | | 287 | | // For type-level changes, the element name is the type name |
| | 5 | 288 | | if (elementType == ApiElementType.Type) |
| | 1 | 289 | | { |
| | 1 | 290 | | return elementName; |
| | | 291 | | } |
| | | 292 | | |
| | | 293 | | // For member-level changes, extract the type from the full name |
| | | 294 | | // Expected format: TypeName.MemberName or Namespace.TypeName.MemberName |
| | 4 | 295 | | var lastDotIndex = elementName.LastIndexOf('.'); |
| | 4 | 296 | | if (lastDotIndex > 0) |
| | 1 | 297 | | { |
| | 1 | 298 | | var typePart = elementName.Substring(0, lastDotIndex); |
| | | 299 | | |
| | | 300 | | // Handle nested types (Type+NestedType.Member) |
| | 1 | 301 | | var plusIndex = typePart.LastIndexOf('+'); |
| | 1 | 302 | | if (plusIndex > 0) |
| | 0 | 303 | | { |
| | 0 | 304 | | return typePart; // Keep the full nested type name |
| | | 305 | | } |
| | | 306 | | |
| | 1 | 307 | | return typePart; |
| | | 308 | | } |
| | | 309 | | |
| | | 310 | | // Fallback for malformed names |
| | 3 | 311 | | return elementName; |
| | 5 | 312 | | } |
| | | 313 | | |
| | | 314 | | private string GetTypeDisplayName(string typeName) |
| | 5 | 315 | | { |
| | | 316 | | // Extract just the type name without namespace for display |
| | 5 | 317 | | var lastDotIndex = typeName.LastIndexOf('.'); |
| | 5 | 318 | | return lastDotIndex > 0 ? typeName.Substring(lastDotIndex + 1) : typeName; |
| | 5 | 319 | | } |
| | | 320 | | |
| | | 321 | | private string GetMemberDisplayName(string elementName, ApiElementType elementType) |
| | 5 | 322 | | { |
| | | 323 | | // For type-level changes, return the simple type name |
| | 5 | 324 | | if (elementType == ApiElementType.Type) |
| | 1 | 325 | | { |
| | 1 | 326 | | var lastDotIndex = elementName.LastIndexOf('.'); |
| | 1 | 327 | | return lastDotIndex > 0 ? elementName.Substring(lastDotIndex + 1) : elementName; |
| | | 328 | | } |
| | | 329 | | |
| | | 330 | | // For member-level changes, return just the member name |
| | 4 | 331 | | var memberDotIndex = elementName.LastIndexOf('.'); |
| | 4 | 332 | | return memberDotIndex > 0 ? elementName.Substring(memberDotIndex + 1) : elementName; |
| | 5 | 333 | | } |
| | | 334 | | |
| | | 335 | | private object[] PrepareBreakingChangesData(IEnumerable<ApiDifference> breakingChanges) |
| | 6 | 336 | | { |
| | 7 | 337 | | return breakingChanges.OrderBy(d => d.Severity) |
| | 1 | 338 | | .ThenBy(d => d.ElementType) |
| | 1 | 339 | | .ThenBy(d => d.ElementName) |
| | 7 | 340 | | .Select(change => new |
| | 7 | 341 | | { |
| | 7 | 342 | | severity = change.Severity.ToString(), |
| | 7 | 343 | | severity_class = change.Severity.ToString().ToLower(), |
| | 7 | 344 | | element_type = change.ElementType, |
| | 7 | 345 | | element_name = change.ElementName, |
| | 7 | 346 | | description = change.Description |
| | 7 | 347 | | }).ToArray(); |
| | 6 | 348 | | } |
| | | 349 | | |
| | | 350 | | private string FormatBooleanValue(bool value) |
| | 102 | 351 | | { |
| | 102 | 352 | | return value ? "<span class=\"boolean-true\">✓ True</span>" : "<span class=\"boolean-false\">✗ False</span>"; |
| | 102 | 353 | | } |
| | | 354 | | |
| | | 355 | | private string RenderChangeGroup(object sectionData) |
| | 0 | 356 | | { |
| | | 357 | | try |
| | 0 | 358 | | { |
| | | 359 | | // Load and parse the change-group template |
| | 0 | 360 | | var templateContent = EmbeddedTemplateLoader.LoadTemplate("change-group.scriban"); |
| | 0 | 361 | | var template = Template.Parse(templateContent); |
| | | 362 | | |
| | | 363 | | // Create a new context for the template with the section data as root |
| | 0 | 364 | | var context = new TemplateContext(); |
| | 0 | 365 | | var scriptObject = new ScriptObject(); |
| | | 366 | | |
| | | 367 | | // Add the section data properties to the script object |
| | 0 | 368 | | if (sectionData != null) |
| | 0 | 369 | | { |
| | 0 | 370 | | var sectionType = sectionData.GetType(); |
| | 0 | 371 | | foreach (var property in sectionType.GetProperties()) |
| | 0 | 372 | | { |
| | 0 | 373 | | var value = property.GetValue(sectionData); |
| | 0 | 374 | | scriptObject.SetValue(property.Name.ToLowerInvariant(), value, true); |
| | 0 | 375 | | } |
| | 0 | 376 | | } |
| | | 377 | | |
| | 0 | 378 | | context.PushGlobal(scriptObject); |
| | | 379 | | |
| | 0 | 380 | | return template.Render(context); |
| | | 381 | | } |
| | 0 | 382 | | catch (Exception ex) |
| | 0 | 383 | | { |
| | 0 | 384 | | return $"<!-- Error rendering change group: {ex.Message} -->"; |
| | | 385 | | } |
| | 0 | 386 | | } |
| | | 387 | | |
| | | 388 | | private string GetCssStyles() |
| | 6 | 389 | | { |
| | | 390 | | try |
| | 6 | 391 | | { |
| | 6 | 392 | | return EmbeddedTemplateLoader.LoadStyles(); |
| | | 393 | | } |
| | 0 | 394 | | catch (Exception ex) |
| | 0 | 395 | | { |
| | 0 | 396 | | System.Diagnostics.Debug.WriteLine($"Warning: Could not load CSS styles, using fallback: {ex.Message}"); |
| | 0 | 397 | | return GetFallbackStyles(); |
| | | 398 | | } |
| | 6 | 399 | | } |
| | | 400 | | |
| | | 401 | | private string GetJavaScriptCode() |
| | 6 | 402 | | { |
| | | 403 | | try |
| | 6 | 404 | | { |
| | 6 | 405 | | return EmbeddedTemplateLoader.LoadScripts(); |
| | | 406 | | } |
| | 0 | 407 | | catch (Exception ex) |
| | 0 | 408 | | { |
| | 0 | 409 | | System.Diagnostics.Debug.WriteLine($"Warning: Could not load JavaScript, using fallback: {ex.Message}"); |
| | 0 | 410 | | return GetFallbackJavaScript(); |
| | | 411 | | } |
| | 6 | 412 | | } |
| | | 413 | | |
| | | 414 | | private string GetFallbackStyles() |
| | 0 | 415 | | { |
| | 0 | 416 | | return "body { font-family: Arial, sans-serif; margin: 20px; }"; |
| | 0 | 417 | | } |
| | | 418 | | |
| | | 419 | | private string GetFallbackJavaScript() |
| | 0 | 420 | | { |
| | 0 | 421 | | return "// Fallback JavaScript"; |
| | 0 | 422 | | } |
| | | 423 | | } |