using System.Net; using System.Net.Http.Json; using System.Text; using System.Text.RegularExpressions; using Markdig; using Web.Models; namespace Web.Services; public class DocsService { private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() .UseYamlFrontMatter() .Build(); private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp" }; private readonly HttpClient _http; private NotesIndex? _index; private readonly Dictionary _markdownCache = new(); public DocsService(HttpClient http) { _http = http; } public async Task GetIndexAsync() { if (_index != null) return _index; _index = await _http.GetFromJsonAsync("docs/notes-index.json") ?? new NotesIndex(); return _index; } public async Task GetNoteAsync(string slug) { var index = await GetIndexAsync(); var noteInfo = index.Notes.FirstOrDefault(n => string.Equals(n.Slug, slug, StringComparison.OrdinalIgnoreCase)); if (noteInfo == null) return null; try { var markdown = await _http.GetStringAsync($"docs/notes/{slug}.md"); return await ParseDocument(slug, noteInfo.Title, markdown); } catch { return null; } } private async Task ParseDocument(string slug, string title, string markdown, int depth = 0) { var doc = new NoteDocument { Slug = slug, Title = title }; var frontmatterLines = new List(); var bodyLines = new List(); var inFrontmatter = false; var frontmatterDone = false; foreach (var line in markdown.Replace("\r\n", "\n").Split('\n')) { var trimmed = line.Trim(); if (!frontmatterDone && trimmed == "---") { if (!inFrontmatter) { inFrontmatter = true; continue; } inFrontmatter = false; frontmatterDone = true; continue; } if (inFrontmatter) frontmatterLines.Add(line); else if (frontmatterDone || !string.IsNullOrWhiteSpace(line)) bodyLines.Add(line); } if (frontmatterLines.Count > 0) { foreach (var line in frontmatterLines) { var colonIdx = line.IndexOf(':'); if (colonIdx > 0) { var key = line[..colonIdx].Trim(); if (string.Equals(key, "category", StringComparison.OrdinalIgnoreCase)) doc.Category = line[(colonIdx + 1)..].Trim().Trim('"'); } } var fmHtml = new StringBuilder(); fmHtml.Append(""); foreach (var line in frontmatterLines) { var colonIdx = line.IndexOf(':'); if (colonIdx > 0) { var key = line[..colonIdx].Trim(); var value = line[(colonIdx + 1)..].Trim().Trim('"'); fmHtml.Append(""); } else { fmHtml.Append(""); } } fmHtml.Append("
"); fmHtml.Append(WebUtility.HtmlEncode(key)); fmHtml.Append(""); var encoded = WebUtility.HtmlEncode(value); fmHtml.Append(ConvertWikiLinks(encoded)); fmHtml.Append("
"); fmHtml.Append(ConvertWikiLinks(WebUtility.HtmlEncode(line.Trim()))); fmHtml.Append("
"); doc.FrontmatterHtml = fmHtml.ToString(); } var body = string.Join("\n", bodyLines); body = ConvertWikiLinks(body); var html = Markdown.ToHtml(body, Pipeline); html = await ResolveEmbedsInHtml(html, depth); doc.HtmlContent = html; return doc; } private async Task ResolveEmbedsInHtml(string html, int depth) { if (depth > 10) return html; var regex = new Regex(@"(?:

)?!\[\[([^\]]+)\]\](?:

)?"); var sb = new StringBuilder(); var lastIndex = 0; foreach (Match match in regex.Matches(html)) { sb.Append(html, lastIndex, match.Index - lastIndex); var filename = match.Groups[1].Value.Trim(); var replacement = await ResolveEmbed(filename, depth); sb.Append(replacement); lastIndex = match.Index + match.Length; } sb.Append(html, lastIndex, html.Length - lastIndex); return sb.ToString(); } private async Task ResolveEmbed(string filename, int depth) { var ext = Path.GetExtension(filename); if (ImageExtensions.Contains(ext)) { return $"\"{WebUtility.HtmlEncode(filename)}\""; } var slug = Slugify(filename); try { var markdown = await GetMarkdownAsync(slug); var embedded = await ParseDocument(slug, filename, markdown, depth + 1); return $"
{embedded.HtmlContent}
"; } catch { return $"
[Embed not found: {WebUtility.HtmlEncode(filename)}]
"; } } private async Task GetMarkdownAsync(string slug) { if (_markdownCache.TryGetValue(slug, out var cached)) return cached; var markdown = await _http.GetStringAsync($"docs/notes/{slug}.md"); _markdownCache[slug] = markdown; return markdown; } private static string ConvertWikiLinks(string text) { return Regex.Replace( text, @"(? { var content = match.Groups[1].Value; var parts = content.Split('|'); var linkText = parts.Length > 1 ? parts[1].Trim() : parts[0].Trim(); var target = parts[0].Trim(); var slug = Slugify(target); return $"{WebUtility.HtmlEncode(linkText)}"; }); } public static string Slugify(string title) { var slug = title.ToLowerInvariant() .Replace(' ', '-') .Replace("'", "") .Replace(".", "") .Replace("(", "") .Replace(")", ""); slug = Regex.Replace(slug, @"[^a-z0-9\-]", ""); slug = Regex.Replace(slug, @"-+", "-"); return slug.Trim('-'); } }