| | | 1 | | using System.Text; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | |
| | | 4 | | namespace 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> |
| | | 10 | | public 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 | | { |
| | 0 | 21 | | var sb = new StringBuilder(); |
| | 0 | 22 | | var root = json.RootElement; |
| | | 23 | | |
| | | 24 | | // Extract title from fields.name |
| | 0 | 25 | | var title = ExtractTitle(root, fallbackTitle); |
| | 0 | 26 | | sb.AppendLine($"# {EscapeMarkdown(title)}"); |
| | 0 | 27 | | sb.AppendLine(); |
| | | 28 | | |
| | | 29 | | // Render attributes section (everything except pk and fields.name) |
| | 0 | 30 | | sb.AppendLine("## Attributes"); |
| | 0 | 31 | | sb.AppendLine(); |
| | | 32 | | |
| | 0 | 33 | | foreach (var property in root.EnumerateObject()) |
| | | 34 | | { |
| | | 35 | | // Skip pk (top-level) |
| | 0 | 36 | | if (property.Name.Equals("pk", StringComparison.OrdinalIgnoreCase)) |
| | | 37 | | { |
| | | 38 | | continue; |
| | | 39 | | } |
| | | 40 | | |
| | | 41 | | // Handle fields object specially |
| | 0 | 42 | | if (property.Name.Equals("fields", StringComparison.OrdinalIgnoreCase) && |
| | 0 | 43 | | property.Value.ValueKind == JsonValueKind.Object) |
| | | 44 | | { |
| | 0 | 45 | | RenderFieldsObject(sb, property.Value, 0); |
| | | 46 | | } |
| | | 47 | | else |
| | | 48 | | { |
| | 0 | 49 | | RenderProperty(sb, property.Name, property.Value, 0); |
| | | 50 | | } |
| | | 51 | | } |
| | | 52 | | |
| | | 53 | | // Attribution footer |
| | 0 | 54 | | sb.AppendLine(); |
| | 0 | 55 | | sb.AppendLine($"*Source: {EscapeMarkdown(displayName)}*"); |
| | | 56 | | |
| | 0 | 57 | | return sb.ToString(); |
| | | 58 | | } |
| | | 59 | | |
| | | 60 | | private static string ExtractTitle(JsonElement root, string fallback) |
| | | 61 | | { |
| | | 62 | | try |
| | | 63 | | { |
| | 0 | 64 | | if (root.TryGetProperty("fields", out var fields) && |
| | 0 | 65 | | fields.TryGetProperty("name", out var nameElement) && |
| | 0 | 66 | | nameElement.ValueKind == JsonValueKind.String) |
| | | 67 | | { |
| | 0 | 68 | | var name = nameElement.GetString(); |
| | 0 | 69 | | if (!string.IsNullOrWhiteSpace(name)) |
| | | 70 | | { |
| | 0 | 71 | | return name; |
| | | 72 | | } |
| | | 73 | | } |
| | 0 | 74 | | } |
| | 0 | 75 | | catch |
| | | 76 | | { |
| | | 77 | | // Parsing failed, use fallback |
| | 0 | 78 | | } |
| | | 79 | | |
| | 0 | 80 | | return fallback; |
| | 0 | 81 | | } |
| | | 82 | | |
| | | 83 | | private static void RenderFieldsObject(StringBuilder sb, JsonElement fields, int indent) |
| | | 84 | | { |
| | 0 | 85 | | foreach (var property in fields.EnumerateObject()) |
| | | 86 | | { |
| | | 87 | | // Skip fields.name (already used as title) |
| | 0 | 88 | | if (property.Name.Equals("name", StringComparison.OrdinalIgnoreCase)) |
| | | 89 | | { |
| | | 90 | | continue; |
| | | 91 | | } |
| | | 92 | | |
| | 0 | 93 | | RenderProperty(sb, property.Name, property.Value, indent); |
| | | 94 | | } |
| | 0 | 95 | | } |
| | | 96 | | |
| | | 97 | | private static void RenderProperty(StringBuilder sb, string key, JsonElement value, int indent) |
| | | 98 | | { |
| | 0 | 99 | | var indentStr = new string(' ', indent * 2); |
| | 0 | 100 | | var displayName = FormatFieldName(key); |
| | | 101 | | |
| | 0 | 102 | | switch (value.ValueKind) |
| | | 103 | | { |
| | | 104 | | case JsonValueKind.String: |
| | 0 | 105 | | var str = value.GetString() ?? string.Empty; |
| | 0 | 106 | | sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**: {EscapeMarkdown(str)}"); |
| | 0 | 107 | | break; |
| | | 108 | | |
| | | 109 | | case JsonValueKind.Number: |
| | 0 | 110 | | sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**: {value}"); |
| | 0 | 111 | | break; |
| | | 112 | | |
| | | 113 | | case JsonValueKind.True: |
| | | 114 | | case JsonValueKind.False: |
| | 0 | 115 | | sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**: {value}"); |
| | 0 | 116 | | break; |
| | | 117 | | |
| | | 118 | | case JsonValueKind.Null: |
| | 0 | 119 | | sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**: null"); |
| | 0 | 120 | | break; |
| | | 121 | | |
| | | 122 | | case JsonValueKind.Array: |
| | 0 | 123 | | RenderArray(sb, displayName, value, indent); |
| | 0 | 124 | | break; |
| | | 125 | | |
| | | 126 | | case JsonValueKind.Object: |
| | 0 | 127 | | sb.AppendLine($"{indentStr}- **{EscapeMarkdown(displayName)}**:"); |
| | 0 | 128 | | RenderObject(sb, value, indent + 1); |
| | | 129 | | break; |
| | | 130 | | |
| | | 131 | | default: |
| | | 132 | | // Undefined - skip |
| | | 133 | | break; |
| | | 134 | | } |
| | 0 | 135 | | } |
| | | 136 | | |
| | | 137 | | private static void RenderArray(StringBuilder sb, string key, JsonElement array, int indent) |
| | | 138 | | { |
| | 0 | 139 | | var indentStr = new string(' ', indent * 2); |
| | 0 | 140 | | sb.AppendLine($"{indentStr}- **{EscapeMarkdown(key)}**:"); |
| | | 141 | | |
| | 0 | 142 | | var childIndent = indent + 1; |
| | 0 | 143 | | var childIndentStr = new string(' ', childIndent * 2); |
| | | 144 | | |
| | 0 | 145 | | foreach (var item in array.EnumerateArray()) |
| | | 146 | | { |
| | 0 | 147 | | switch (item.ValueKind) |
| | | 148 | | { |
| | | 149 | | case JsonValueKind.String: |
| | 0 | 150 | | var str = item.GetString() ?? string.Empty; |
| | 0 | 151 | | sb.AppendLine($"{childIndentStr}- {EscapeMarkdown(str)}"); |
| | 0 | 152 | | break; |
| | | 153 | | |
| | | 154 | | case JsonValueKind.Number: |
| | | 155 | | case JsonValueKind.True: |
| | | 156 | | case JsonValueKind.False: |
| | 0 | 157 | | sb.AppendLine($"{childIndentStr}- {item}"); |
| | 0 | 158 | | break; |
| | | 159 | | |
| | | 160 | | case JsonValueKind.Object: |
| | 0 | 161 | | sb.AppendLine($"{childIndentStr}- "); |
| | 0 | 162 | | RenderObject(sb, item, childIndent + 1); |
| | 0 | 163 | | break; |
| | | 164 | | |
| | | 165 | | case JsonValueKind.Array: |
| | | 166 | | // Nested arrays - render inline or recursively (keep simple) |
| | 0 | 167 | | sb.AppendLine($"{childIndentStr}- (nested array)"); |
| | | 168 | | break; |
| | | 169 | | |
| | | 170 | | default: |
| | | 171 | | break; |
| | | 172 | | } |
| | | 173 | | } |
| | 0 | 174 | | } |
| | | 175 | | |
| | | 176 | | private static void RenderObject(StringBuilder sb, JsonElement obj, int indent) |
| | | 177 | | { |
| | 0 | 178 | | foreach (var property in obj.EnumerateObject()) |
| | | 179 | | { |
| | 0 | 180 | | RenderProperty(sb, property.Name, property.Value, indent); |
| | | 181 | | } |
| | 0 | 182 | | } |
| | | 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 | | { |
| | 0 | 190 | | if (string.IsNullOrWhiteSpace(fieldName)) |
| | | 191 | | { |
| | 0 | 192 | | return fieldName; |
| | | 193 | | } |
| | | 194 | | |
| | | 195 | | // Replace underscores with spaces |
| | 0 | 196 | | var withSpaces = fieldName.Replace('_', ' '); |
| | | 197 | | |
| | | 198 | | // Insert spaces before capital letters (camelCase handling) |
| | 0 | 199 | | var sb = new StringBuilder(); |
| | 0 | 200 | | for (int i = 0; i < withSpaces.Length; i++) |
| | | 201 | | { |
| | 0 | 202 | | var ch = withSpaces[i]; |
| | | 203 | | |
| | | 204 | | // Insert space before uppercase if not at start and previous char is lowercase |
| | 0 | 205 | | if (i > 0 && char.IsUpper(ch) && char.IsLower(withSpaces[i - 1])) |
| | | 206 | | { |
| | 0 | 207 | | sb.Append(' '); |
| | | 208 | | } |
| | | 209 | | |
| | 0 | 210 | | sb.Append(ch); |
| | | 211 | | } |
| | | 212 | | |
| | | 213 | | // Convert to title case |
| | 0 | 214 | | var words = sb.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries); |
| | 0 | 215 | | var titleCased = words.Select(w => |
| | 0 | 216 | | w.Length > 0 ? char.ToUpper(w[0]) + w.Substring(1).ToLower() : w); |
| | | 217 | | |
| | 0 | 218 | | 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 | | { |
| | 0 | 227 | | if (string.IsNullOrEmpty(text)) |
| | | 228 | | { |
| | 0 | 229 | | return text; |
| | | 230 | | } |
| | | 231 | | |
| | 0 | 232 | | return text |
| | 0 | 233 | | .Replace("<", "<") |
| | 0 | 234 | | .Replace(">", ">"); |
| | | 235 | | } |
| | | 236 | | } |