< 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
0%
Covered lines: 0
Uncovered lines: 85
Coverable lines: 85
Total lines: 236
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 57
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
RenderMarkdown(...)0%7280%
ExtractTitle(...)0%7280%
RenderFieldsObject(...)0%2040%
RenderProperty(...)0%110100%
RenderArray(...)0%132110%
RenderObject(...)0%620%
FormatFieldName(...)0%156120%
EscapeMarkdown(...)0%620%

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    {
 021        var sb = new StringBuilder();
 022        var root = json.RootElement;
 23
 24        // Extract title from fields.name
 025        var title = ExtractTitle(root, fallbackTitle);
 026        sb.AppendLine($"# {EscapeMarkdown(title)}");
 027        sb.AppendLine();
 28
 29        // Render attributes section (everything except pk and fields.name)
 030        sb.AppendLine("## Attributes");
 031        sb.AppendLine();
 32
 033        foreach (var property in root.EnumerateObject())
 34        {
 35            // Skip pk (top-level)
 036            if (property.Name.Equals("pk", StringComparison.OrdinalIgnoreCase))
 37            {
 38                continue;
 39            }
 40
 41            // Handle fields object specially
 042            if (property.Name.Equals("fields", StringComparison.OrdinalIgnoreCase) &&
 043                property.Value.ValueKind == JsonValueKind.Object)
 44            {
 045                RenderFieldsObject(sb, property.Value, 0);
 46            }
 47            else
 48            {
 049                RenderProperty(sb, property.Name, property.Value, 0);
 50            }
 51        }
 52
 53        // Attribution footer
 054        sb.AppendLine();
 055        sb.AppendLine($"*Source: {EscapeMarkdown(displayName)}*");
 56
 057        return sb.ToString();
 58    }
 59
 60    private static string ExtractTitle(JsonElement root, string fallback)
 61    {
 62        try
 63        {
 064            if (root.TryGetProperty("fields", out var fields) &&
 065                fields.TryGetProperty("name", out var nameElement) &&
 066                nameElement.ValueKind == JsonValueKind.String)
 67            {
 068                var name = nameElement.GetString();
 069                if (!string.IsNullOrWhiteSpace(name))
 70                {
 071                    return name;
 72                }
 73            }
 074        }
 075        catch
 76        {
 77            // Parsing failed, use fallback
 078        }
 79
 080        return fallback;
 081    }
 82
 83    private static void RenderFieldsObject(StringBuilder sb, JsonElement fields, int indent)
 84    {
 085        foreach (var property in fields.EnumerateObject())
 86        {
 87            // Skip fields.name (already used as title)
 088            if (property.Name.Equals("name", StringComparison.OrdinalIgnoreCase))
 89            {
 90                continue;
 91            }
 92
 093            RenderProperty(sb, property.Name, property.Value, indent);
 94        }
 095    }
 96
 97    private static void RenderProperty(StringBuilder sb, string key, JsonElement value, int indent)
 98    {
 099        var indentStr = new string(' ', indent * 2);
 0100        var displayName = FormatFieldName(key);
 101
 0102        switch (value.ValueKind)
 103        {
 104            case JsonValueKind.String:
 0105                var str = value.GetString() ?? string.Empty;
 0106                sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**: {EscapeMarkdown(str)}");
 0107                break;
 108
 109            case JsonValueKind.Number:
 0110                sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**: {value}");
 0111                break;
 112
 113            case JsonValueKind.True:
 114            case JsonValueKind.False:
 0115                sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**: {value}");
 0116                break;
 117
 118            case JsonValueKind.Null:
 0119                sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**: null");
 0120                break;
 121
 122            case JsonValueKind.Array:
 0123                RenderArray(sb, displayName, value, indent);
 0124                break;
 125
 126            case JsonValueKind.Object:
 0127                sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**:");
 0128                RenderObject(sb, value, indent + 1);
 129                break;
 130
 131            default:
 132                // Undefined - skip
 133                break;
 134        }
 0135    }
 136
 137    private static void RenderArray(StringBuilder sb, string key, JsonElement array, int indent)
 138    {
 0139        var indentStr = new string(' ', indent * 2);
 0140        sb.AppendLine($"{indentStr}- **{EscapeMarkdown(key)}**:");
 141
 0142        var childIndent = indent + 1;
 0143        var childIndentStr = new string(' ', childIndent * 2);
 144
 0145        foreach (var item in array.EnumerateArray())
 146        {
 0147            switch (item.ValueKind)
 148            {
 149                case JsonValueKind.String:
 0150                    var str = item.GetString() ?? string.Empty;
 0151                    sb.AppendLine($"{childIndentStr}- {EscapeMarkdown(str)}");
 0152                    break;
 153
 154                case JsonValueKind.Number:
 155                case JsonValueKind.True:
 156                case JsonValueKind.False:
 0157                    sb.AppendLine($"{childIndentStr}- {item}");
 0158                    break;
 159
 160                case JsonValueKind.Object:
 0161                    sb.AppendLine($"{childIndentStr}- ");
 0162                    RenderObject(sb, item, childIndent + 1);
 0163                    break;
 164
 165                case JsonValueKind.Array:
 166                    // Nested arrays - render inline or recursively (keep simple)
 0167                    sb.AppendLine($"{childIndentStr}- (nested array)");
 168                    break;
 169
 170                default:
 171                    break;
 172            }
 173        }
 0174    }
 175
 176    private static void RenderObject(StringBuilder sb, JsonElement obj, int indent)
 177    {
 0178        foreach (var property in obj.EnumerateObject())
 179        {
 0180            RenderProperty(sb, property.Name, property.Value, indent);
 181        }
 0182    }
 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    {
 0190        if (string.IsNullOrWhiteSpace(fieldName))
 191        {
 0192            return fieldName;
 193        }
 194
 195        // Replace underscores with spaces
 0196        var withSpaces = fieldName.Replace('_', ' ');
 197
 198        // Insert spaces before capital letters (camelCase handling)
 0199        var sb = new StringBuilder();
 0200        for (int i = 0; i < withSpaces.Length; i++)
 201        {
 0202            var ch = withSpaces[i];
 203
 204            // Insert space before uppercase if not at start and previous char is lowercase
 0205            if (i > 0 && char.IsUpper(ch) && char.IsLower(withSpaces[i - 1]))
 206            {
 0207                sb.Append(' ');
 208            }
 209
 0210            sb.Append(ch);
 211        }
 212
 213        // Convert to title case
 0214        var words = sb.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries);
 0215        var titleCased = words.Select(w =>
 0216            w.Length > 0 ? char.ToUpper(w[0]) + w.Substring(1).ToLower() : w);
 217
 0218        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    {
 0227        if (string.IsNullOrEmpty(text))
 228        {
 0229            return text;
 230        }
 231
 0232        return text
 0233            .Replace("<", "&lt;")
 0234            .Replace(">", "&gt;");
 235    }
 236}