< Summary

Information
Class: Chronicis.Api.Services.ExternalLinks.GenericJsonMarkdownRenderer
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExternalLinks/GenericJsonMarkdownRenderer.cs
Line coverage
100%
Covered lines: 85
Uncovered lines: 0
Coverable lines: 85
Total lines: 236
Line coverage: 100%
Branch coverage
100%
Covered branches: 51
Total branches: 51
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
RenderMarkdown(...)100%88100%
ExtractTitle(...)100%88100%
RenderFieldsObject(...)100%44100%
RenderProperty(...)100%88100%
RenderArray(...)100%99100%
RenderObject(...)100%22100%
FormatFieldName(...)100%1010100%
EscapeMarkdown(...)100%22100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExternalLinks/GenericJsonMarkdownRenderer.cs

#LineLine coverage
 1using System.Text;
 2using System.Text.Json;
 3
 4namespace Chronicis.Api.Services.ExternalLinks;
 5
 6/// <summary>
 7/// Renders JSON content as generic markdown for blob-backed external link providers.
 8/// Phase 4: Generic rendering without category-specific formatting.
 9/// </summary>
 10public static class GenericJsonMarkdownRenderer
 11{
 12    /// <summary>
 13    /// Renders JSON document as markdown.
 14    /// </summary>
 15    /// <param name="json">Parsed JSON document.</param>
 16    /// <param name="displayName">Provider display name for attribution footer.</param>
 17    /// <param name="fallbackTitle">Fallback title if fields.name is missing.</param>
 18    /// <returns>Markdown string.</returns>
 19    public static string RenderMarkdown(JsonDocument json, string displayName, string fallbackTitle)
 20    {
 621        var sb = new StringBuilder();
 622        var root = json.RootElement;
 23
 24        // Extract title from fields.name
 625        var title = ExtractTitle(root, fallbackTitle);
 626        sb.AppendLine($"# {EscapeMarkdown(title)}");
 627        sb.AppendLine();
 28
 29        // Render attributes section (everything except pk and fields.name)
 630        sb.AppendLine("## Attributes");
 631        sb.AppendLine();
 32
 3033        foreach (var property in root.EnumerateObject())
 34        {
 35            // Skip pk (top-level)
 936            if (property.Name.Equals("pk", StringComparison.OrdinalIgnoreCase))
 37            {
 38                continue;
 39            }
 40
 41            // Handle fields object specially
 842            if (property.Name.Equals("fields", StringComparison.OrdinalIgnoreCase) &&
 843                property.Value.ValueKind == JsonValueKind.Object)
 44            {
 545                RenderFieldsObject(sb, property.Value, 0);
 46            }
 47            else
 48            {
 349                RenderProperty(sb, property.Name, property.Value, 0);
 50            }
 51        }
 52
 53        // Attribution footer
 654        sb.AppendLine();
 655        sb.AppendLine($"*Source: {EscapeMarkdown(displayName)}*");
 56
 657        return sb.ToString();
 58    }
 59
 60    private static string ExtractTitle(JsonElement root, string fallback)
 61    {
 62        try
 63        {
 664            if (root.TryGetProperty("fields", out var fields) &&
 665                fields.TryGetProperty("name", out var nameElement) &&
 666                nameElement.ValueKind == JsonValueKind.String)
 67            {
 468                var name = nameElement.GetString();
 469                if (!string.IsNullOrWhiteSpace(name))
 70                {
 371                    return name;
 72                }
 73            }
 274        }
 175        catch
 76        {
 77            // Parsing failed, use fallback
 178        }
 79
 380        return fallback;
 381    }
 82
 83    private static void RenderFieldsObject(StringBuilder sb, JsonElement fields, int indent)
 84    {
 4085        foreach (var property in fields.EnumerateObject())
 86        {
 87            // Skip fields.name (already used as title)
 1588            if (property.Name.Equals("name", StringComparison.OrdinalIgnoreCase))
 89            {
 90                continue;
 91            }
 92
 1193            RenderProperty(sb, property.Name, property.Value, indent);
 94        }
 595    }
 96
 97    private static void RenderProperty(StringBuilder sb, string key, JsonElement value, int indent)
 98    {
 1799        var indentStr = new string(' ', indent * 2);
 17100        var displayName = FormatFieldName(key);
 101
 17102        switch (value.ValueKind)
 103        {
 104            case JsonValueKind.String:
 6105                var str = value.GetString()!;
 6106                sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**: {EscapeMarkdown(str)}");
 6107                break;
 108
 109            case JsonValueKind.Number:
 3110                sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**: {value}");
 3111                break;
 112
 113            case JsonValueKind.True:
 114            case JsonValueKind.False:
 1115                sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**: {value}");
 1116                break;
 117
 118            case JsonValueKind.Null:
 1119                sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**: null");
 1120                break;
 121
 122            case JsonValueKind.Array:
 4123                RenderArray(sb, displayName, value, indent);
 4124                break;
 125
 126            case JsonValueKind.Object:
 1127                sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**:");
 1128                RenderObject(sb, value, indent + 1);
 129                break;
 130
 131            default:
 132                // Undefined - skip
 133                break;
 134        }
 1135    }
 136
 137    private static void RenderArray(StringBuilder sb, string key, JsonElement array, int indent)
 138    {
 4139        var indentStr = new string(' ', indent * 2);
 4140        sb.AppendLine($"{indentStr}- **{EscapeMarkdown(key)}**:");
 141
 4142        var childIndent = indent + 1;
 4143        var childIndentStr = new string(' ', childIndent * 2);
 144
 22145        foreach (var item in array.EnumerateArray())
 146        {
 7147            switch (item.ValueKind)
 148            {
 149                case JsonValueKind.String:
 2150                    var str = item.GetString()!;
 2151                    sb.AppendLine($"{childIndentStr}- {EscapeMarkdown(str)}");
 2152                    break;
 153
 154                case JsonValueKind.Number:
 155                case JsonValueKind.True:
 156                case JsonValueKind.False:
 2157                    sb.AppendLine($"{childIndentStr}- {item}");
 2158                    break;
 159
 160                case JsonValueKind.Object:
 1161                    sb.AppendLine($"{childIndentStr}- ");
 1162                    RenderObject(sb, item, childIndent + 1);
 1163                    break;
 164
 165                case JsonValueKind.Array:
 166                    // Nested arrays - render inline or recursively (keep simple)
 1167                    sb.AppendLine($"{childIndentStr}- (nested array)");
 168                    break;
 169
 170                default:
 171                    break;
 172            }
 173        }
 4174    }
 175
 176    private static void RenderObject(StringBuilder sb, JsonElement obj, int indent)
 177    {
 8178        foreach (var property in obj.EnumerateObject())
 179        {
 2180            RenderProperty(sb, property.Name, property.Value, indent);
 181        }
 2182    }
 183
 184    /// <summary>
 185    /// Formats field names for display: snake_case or camelCase to Title Case.
 186    /// Examples: armor_class -> Armor Class, spellLevel -> Spell Level
 187    /// </summary>
 188    private static string FormatFieldName(string fieldName)
 189    {
 18190        if (string.IsNullOrWhiteSpace(fieldName))
 191        {
 1192            return fieldName;
 193        }
 194
 195        // Replace underscores with spaces
 17196        var withSpaces = fieldName.Replace('_', ' ');
 197
 198        // Insert spaces before capital letters (camelCase handling)
 17199        var sb = new StringBuilder();
 226200        for (int i = 0; i < withSpaces.Length; i++)
 201        {
 96202            var ch = withSpaces[i];
 203
 204            // Insert space before uppercase if not at start and previous char is lowercase
 96205            if (i > 0 && char.IsUpper(ch) && char.IsLower(withSpaces[i - 1]))
 206            {
 1207                sb.Append(' ');
 208            }
 209
 96210            sb.Append(ch);
 211        }
 212
 213        // Convert to title case
 17214        var words = sb.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries);
 17215        var titleCased = words.Select(w =>
 17216            w.Length > 0 ? char.ToUpper(w[0]) + w[1..].ToLowerInvariant() : w);
 217
 17218        return string.Join(" ", titleCased);
 219    }
 220
 221    /// <summary>
 222    /// Escapes markdown special characters to prevent HTML injection.
 223    /// Minimal escaping: angle brackets to prevent tags.
 224    /// </summary>
 225    private static string EscapeMarkdown(string text)
 226    {
 37227        if (string.IsNullOrEmpty(text))
 228        {
 1229            return text;
 230        }
 231
 36232        return text
 36233            .Replace("<", "&lt;")
 36234            .Replace(">", "&gt;");
 235    }
 236}