241 lines
8.1 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|