using Markdig; using Microsoft.Extensions.Caching.Memory; using QRRapidoApp.Models; using QRRapidoApp.Models.ViewModels; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; namespace QRRapidoApp.Services { public class MarkdownService : IMarkdownService { private readonly IMemoryCache _cache; private readonly IWebHostEnvironment _env; private readonly ILogger _logger; private readonly MarkdownPipeline _pipeline; private readonly IDeserializer _yamlDeserializer; private const string CACHE_KEY_PREFIX = "Tutorial_"; private const string CACHE_KEY_ALL = "AllTutorials_"; private const int CACHE_DURATION_HOURS = 1; public MarkdownService(IMemoryCache cache, IWebHostEnvironment env, ILogger logger) { _cache = cache; _env = env; _logger = logger; // Configure Markdig pipeline for advanced markdown features _pipeline = new MarkdownPipelineBuilder() .UseAdvancedExtensions() .UseAutoLinks() .UseEmojiAndSmiley() .Build(); // Configure YAML deserializer _yamlDeserializer = new DeserializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .IgnoreUnmatchedProperties() .Build(); } public async Task GetArticleAsync(string slug, string culture, string contentFolder = "Tutoriais") { var cacheKey = $"{CACHE_KEY_PREFIX}{contentFolder}_{culture}_{slug}"; // Try get from cache if (_cache.TryGetValue(cacheKey, out ArticleViewModel? cachedArticle)) { _logger.LogInformation("Article served from cache: {Slug} ({Culture})", slug, culture); return cachedArticle; } try { var contentPath = Path.Combine(_env.ContentRootPath, "Content", contentFolder); var fileName = $"{slug}.{culture}.md"; var filePath = Path.Combine(contentPath, fileName); if (!File.Exists(filePath)) { _logger.LogWarning("Article file not found: {FilePath}", filePath); return null; } var fileContent = await File.ReadAllTextAsync(filePath); var article = ParseMarkdownWithFrontmatter(fileContent, slug); if (article == null) { _logger.LogError("Failed to parse article: {Slug} ({Culture})", slug, culture); return null; } // Set file metadata var fileInfo = new FileInfo(filePath); article.LastModified = fileInfo.LastWriteTimeUtc; article.Metadata.Culture = culture; // Cache the article var cacheOptions = new MemoryCacheEntryOptions() .SetAbsoluteExpiration(TimeSpan.FromHours(CACHE_DURATION_HOURS)); _cache.Set(cacheKey, article, cacheOptions); _logger.LogInformation("Article loaded and cached: {Slug} ({Culture})", slug, culture); return article; } catch (Exception ex) { _logger.LogError(ex, "Error loading article: {Slug} ({Culture})", slug, culture); return null; } } public async Task> GetAllArticlesAsync(string culture, string contentFolder = "Tutoriais") { var cacheKey = $"{CACHE_KEY_ALL}{contentFolder}_{culture}"; // Try get from cache if (_cache.TryGetValue(cacheKey, out List? cachedList)) { _logger.LogInformation("Articles list served from cache ({Culture})", culture); return cachedList ?? new List(); } try { var articles = new List(); var contentPath = Path.Combine(_env.ContentRootPath, "Content", contentFolder); if (!Directory.Exists(contentPath)) { _logger.LogWarning("Tutoriais directory not found: {Path}", contentPath); return articles; } var pattern = $"*.{culture}.md"; var files = Directory.GetFiles(contentPath, pattern); foreach (var file in files) { try { var fileContent = await File.ReadAllTextAsync(file); var slug = Path.GetFileNameWithoutExtension(Path.GetFileNameWithoutExtension(file)); var article = ParseMarkdownWithFrontmatter(fileContent, slug); if (article?.Metadata != null) { article.Metadata.Culture = culture; article.Metadata.Slug = slug; articles.Add(article.Metadata); } } catch (Exception ex) { _logger.LogError(ex, "Error parsing article file: {File}", file); } } // Sort by date (newest first) articles = articles.OrderByDescending(a => a.Date).ToList(); // Cache the list var cacheOptions = new MemoryCacheEntryOptions() .SetAbsoluteExpiration(TimeSpan.FromHours(CACHE_DURATION_HOURS)); _cache.Set(cacheKey, articles, cacheOptions); _logger.LogInformation("Loaded {Count} articles for culture {Culture}", articles.Count, culture); return articles; } catch (Exception ex) { _logger.LogError(ex, "Error loading articles list for culture {Culture}", culture); return new List(); } } public async Task> GetAllArticlesForSitemapAsync() { try { var allArticles = new List(); var contentPath = Path.Combine(_env.ContentRootPath, "Content", "Tutoriais"); if (!Directory.Exists(contentPath)) { _logger.LogWarning("Tutoriais directory not found for sitemap: {Path}", contentPath); return allArticles; } var files = Directory.GetFiles(contentPath, "*.md"); foreach (var file in files) { try { var fileContent = await File.ReadAllTextAsync(file); var fileName = Path.GetFileName(file); // Extract slug and culture from filename (e.g., "como-criar-qr.pt-BR.md") var parts = fileName.Replace(".md", "").Split('.'); if (parts.Length < 2) continue; var slug = parts[0]; var culture = parts[1]; var article = ParseMarkdownWithFrontmatter(fileContent, slug); if (article?.Metadata != null) { article.Metadata.Culture = culture; article.Metadata.Slug = slug; var fileInfo = new FileInfo(file); article.Metadata.LastMod = fileInfo.LastWriteTimeUtc; allArticles.Add(article.Metadata); } } catch (Exception ex) { _logger.LogError(ex, "Error parsing article for sitemap: {File}", file); } } _logger.LogInformation("Loaded {Count} total articles for sitemap", allArticles.Count); return allArticles; } catch (Exception ex) { _logger.LogError(ex, "Error loading articles for sitemap"); return new List(); } } private ArticleViewModel? ParseMarkdownWithFrontmatter(string content, string slug) { try { // Extract frontmatter if (!content.StartsWith("---")) { _logger.LogWarning("Article missing frontmatter: {Slug}", slug); return null; } var endOfFrontmatter = content.IndexOf("---", 3); if (endOfFrontmatter == -1) { _logger.LogWarning("Article frontmatter not closed: {Slug}", slug); return null; } var frontmatterYaml = content.Substring(3, endOfFrontmatter - 3).Trim(); var markdownContent = content.Substring(endOfFrontmatter + 3).Trim(); // Parse YAML frontmatter var metadata = _yamlDeserializer.Deserialize(frontmatterYaml); if (metadata == null) { _logger.LogWarning("Failed to deserialize frontmatter: {Slug}", slug); return null; } // Calculate reading time (average 200 words per minute) var wordCount = markdownContent.Split(new[] { ' ', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).Length; metadata.ReadingTimeMinutes = Math.Max(1, (int)Math.Ceiling(wordCount / 200.0)); // Convert Markdown to HTML var htmlContent = Markdown.ToHtml(markdownContent, _pipeline); return new ArticleViewModel { Metadata = metadata, HtmlContent = htmlContent, Slug = slug, LastModified = metadata.LastMod }; } catch (Exception ex) { _logger.LogError(ex, "Error parsing markdown for {Slug}", slug); return null; } } } }