BCards/src/BCards.Web/Areas/Tutoriais/Services/MarkdownService.cs

241 lines
8.1 KiB
C#

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<MarkdownService> _logger;
private readonly string _contentBasePath;
private readonly MarkdownPipeline _markdownPipeline;
private readonly IDeserializer _yamlDeserializer;
public MarkdownService(
IMemoryCache cache,
ILogger<MarkdownService> 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<ArticleViewModel?> 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<List<ArticleMetadata>> GetArticlesByCategoryAsync(string category, string culture)
{
var cacheKey = $"CategoryArticles_{category}_{culture}";
if (_cache.TryGetValue(cacheKey, out List<ArticleMetadata>? cached))
{
return cached ?? new List<ArticleMetadata>();
}
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<ArticleMetadata>();
}
var articles = new List<ArticleMetadata>();
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<List<ArticleMetadata>> GetAllArticlesAsync(string baseFolder, string culture)
{
var cacheKey = $"AllArticles_{baseFolder}_{culture}";
if (_cache.TryGetValue(cacheKey, out List<ArticleMetadata>? cached))
{
return cached ?? new List<ArticleMetadata>();
}
var folderPath = Path.Combine(_contentBasePath, baseFolder);
if (!Directory.Exists(folderPath))
{
_logger.LogWarning("Pasta não encontrada: {Path}", folderPath);
return new List<ArticleMetadata>();
}
var articles = new List<ArticleMetadata>();
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<ArticleMetadata>(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);
}
}
}