using BCards.Web.Areas.Tutoriais.Models; using BCards.Web.Areas.Tutoriais.Models.ViewModels; using Markdig; using Microsoft.Extensions.Caching.Memory; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; namespace BCards.Web.Areas.Tutoriais.Services; public class MarkdownService : IMarkdownService { private readonly IMemoryCache _cache; private readonly ILogger _logger; private readonly string _contentBasePath; private readonly MarkdownPipeline _markdownPipeline; private readonly IDeserializer _yamlDeserializer; public MarkdownService( IMemoryCache cache, ILogger logger, IWebHostEnvironment environment) { _cache = cache; _logger = logger; _contentBasePath = Path.Combine(environment.ContentRootPath, "Content"); // Pipeline Markdig com extensões avançadas _markdownPipeline = new MarkdownPipelineBuilder() .UseAdvancedExtensions() // Tables, footnotes, etc. .UseAutoLinks() // Auto-link URLs .UseEmphasisExtras() // ~~strikethrough~~ .UseGenericAttributes() // {#id .class} .DisableHtml() // Segurança: bloqueia HTML inline .Build(); // Deserializador YAML _yamlDeserializer = new DeserializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .IgnoreUnmatchedProperties() .Build(); } public async Task GetArticleAsync(string relativePath, string culture) { var cacheKey = $"Article_{relativePath}_{culture}"; // Verificar cache if (_cache.TryGetValue(cacheKey, out ArticleViewModel? cachedArticle)) { _logger.LogDebug("Artigo encontrado no cache: {Path}", relativePath); return cachedArticle; } // Construir caminho completo var fullPath = Path.Combine(_contentBasePath, $"{relativePath}.{culture}.md"); if (!File.Exists(fullPath)) { _logger.LogWarning("Arquivo não encontrado: {Path}", fullPath); return null; } try { var content = await File.ReadAllTextAsync(fullPath); var (metadata, markdownContent) = ExtractFrontmatter(content); if (metadata == null) { _logger.LogError("Frontmatter inválido em: {Path}", fullPath); return null; } // Processar markdown → HTML var htmlContent = Markdown.ToHtml(markdownContent, _markdownPipeline); // Calcular tempo de leitura (200 palavras/minuto) var wordCount = markdownContent.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; metadata.ReadingTimeMinutes = Math.Max(1, wordCount / 200); // Extrair slug do path metadata.Slug = Path.GetFileNameWithoutExtension( Path.GetFileNameWithoutExtension(relativePath.Split('/').Last()) ); metadata.Culture = culture; var article = new ArticleViewModel { Metadata = metadata, HtmlContent = htmlContent, Slug = metadata.Slug, LastModified = File.GetLastWriteTimeUtc(fullPath) }; // Cache por 1 hora _cache.Set(cacheKey, article, TimeSpan.FromHours(1)); _logger.LogInformation("Artigo processado e cacheado: {Path}", relativePath); return article; } catch (Exception ex) { _logger.LogError(ex, "Erro ao processar artigo: {Path}", fullPath); return null; } } public async Task> GetArticlesByCategoryAsync(string category, string culture) { var cacheKey = $"CategoryArticles_{category}_{culture}"; if (_cache.TryGetValue(cacheKey, out List? cached)) { return cached ?? new List(); } var categoryPath = Path.Combine(_contentBasePath, "Tutoriais", category); if (!Directory.Exists(categoryPath)) { _logger.LogWarning("Diretório de categoria não encontrado: {Path}", categoryPath); return new List(); } var articles = new List(); var files = Directory.GetFiles(categoryPath, $"*.{culture}.md"); foreach (var file in files) { try { var content = await File.ReadAllTextAsync(file); var (metadata, _) = ExtractFrontmatter(content); if (metadata != null) { var slug = Path.GetFileNameWithoutExtension( Path.GetFileNameWithoutExtension(file) ); metadata.Slug = slug; metadata.Culture = culture; metadata.Category = category; articles.Add(metadata); } } catch (Exception ex) { _logger.LogError(ex, "Erro ao processar arquivo: {File}", file); } } // Ordenar por data (mais recentes primeiro) articles = articles.OrderByDescending(a => a.Date).ToList(); _cache.Set(cacheKey, articles, TimeSpan.FromHours(1)); return articles; } public async Task> GetAllArticlesAsync(string baseFolder, string culture) { var cacheKey = $"AllArticles_{baseFolder}_{culture}"; if (_cache.TryGetValue(cacheKey, out List? cached)) { return cached ?? new List(); } var folderPath = Path.Combine(_contentBasePath, baseFolder); if (!Directory.Exists(folderPath)) { _logger.LogWarning("Pasta não encontrada: {Path}", folderPath); return new List(); } var articles = new List(); var files = Directory.GetFiles(folderPath, $"*.{culture}.md", SearchOption.AllDirectories); foreach (var file in files) { try { var content = await File.ReadAllTextAsync(file); var (metadata, _) = ExtractFrontmatter(content); if (metadata != null) { var slug = Path.GetFileNameWithoutExtension( Path.GetFileNameWithoutExtension(file) ); metadata.Slug = slug; metadata.Culture = culture; articles.Add(metadata); } } catch (Exception ex) { _logger.LogError(ex, "Erro ao processar arquivo: {File}", file); } } articles = articles.OrderByDescending(a => a.Date).ToList(); _cache.Set(cacheKey, articles, TimeSpan.FromHours(1)); return articles; } private (ArticleMetadata? metadata, string content) ExtractFrontmatter(string fileContent) { var lines = fileContent.Split('\n'); if (lines.Length < 3 || !lines[0].Trim().Equals("---")) { _logger.LogWarning("Frontmatter não encontrado (deve começar com ---)"); return (null, fileContent); } var endIndex = Array.FindIndex(lines, 1, line => line.Trim().Equals("---")); if (endIndex == -1) { _logger.LogWarning("Frontmatter mal formatado (falta --- de fechamento)"); return (null, fileContent); } try { var yamlContent = string.Join('\n', lines[1..endIndex]); var metadata = _yamlDeserializer.Deserialize(yamlContent); var markdownContent = string.Join('\n', lines[(endIndex + 1)..]); return (metadata, markdownContent); } catch (Exception ex) { _logger.LogError(ex, "Erro ao deserializar YAML frontmatter"); return (null, fileContent); } } }