42 KiB
Relatório de Viabilidade: Sistema de Artigos BCards
Análise Técnica para Implementação de Tutoriais Baseados em Markdown usando ASP.NET MVC Areas
Data: 02/11/2025 Projeto: BCards (vcart.me.novo) Referência: Sistema de artigos QRRapido Arquitetura: ASP.NET MVC Areas (padrão já usado no BCards) Autor: Claude Code Analysis
📋 Sumário Executivo
VEREDICTO: ✅ TOTALMENTE VIÁVEL
A implementação de um sistema de artigos baseado em Markdown no BCards é altamente viável e apresenta compatibilidade arquitetural de 100% com os padrões existentes do projeto. O BCards já utiliza ASP.NET MVC Areas (Area "Support"), facilitando ainda mais a implementação.
Tempo Estimado: 4-6 horas Complexidade: Baixa/Média Risco: Baixo ROI: Alto (SEO + Educação de usuários) Arquitetura: Areas (seguindo padrão existente)
🎯 Requisitos do Usuário
URLs Desejadas
1. Tutoriais por Categoria
https://bcards.site/tutoriais/{categoria}/{slug}
Exemplos:
- /tutoriais/tecnologia/como-criar-um-site-ou-bcard-com-os-links-de-minha-empresa
- /tutoriais/advocacia/como-advogados-podem-usar-bcards
- /tutoriais/papelaria/criando-cartoes-digitais-para-clientes
2. Artigos Inspiracionais (Sem Categoria)
https://bcards.site/artigos/{slug}
Exemplos:
- /artigos/transformacao-digital-para-pequenos-negocios
- /artigos/futuro-dos-cartoes-de-visita-digitais
Base de Categorias
Arquivo: /mnt/c/vscode/vcart.me.novo/BCardsDB_Dev.categories.json
Total: 30+ categorias (Advocacia, Tecnologia, Papelaria, etc.)
Estrutura:
{
"slug": "tecnologia",
"name": "Tecnologia",
"icon": "💻",
"seoKeywords": ["desenvolvimento", "software", "TI"]
}
📊 Análise Comparativa: QRRapido vs BCards
Arquitetura - Compatibilidade Perfeita
| Aspecto | QRRapido | BCards | Compatibilidade |
|---|---|---|---|
| Framework | ASP.NET Core MVC | ASP.NET Core 8.0 MVC | ✅ 100% |
| Padrão | MVC + Service + Repository | MVC + Service + Repository | ✅ 100% |
| Areas | ❌ Não usa | ✅ Usa (Area "Support") | ✅ 100% (vantagem!) |
| Database | MongoDB | MongoDB 3.4.2 | ✅ 100% |
| DI Container | ASP.NET Core nativo | ASP.NET Core nativo | ✅ 100% |
| Cache | IMemoryCache | IMemoryCache + Middleware | ✅ 100% |
| Views | Razor + Bootstrap | Razor + Bootstrap 5 | ✅ 100% |
BCards já usa Areas!
Descoberta Importante: O BCards já implementa ASP.NET MVC Areas:
// Program.cs (linha 732-736)
app.MapAreaControllerRoute(
name: "support-area",
areaName: "Support",
pattern: "Support/{controller=Support}/{action=Index}/{id?}");
Estrutura Existente:
/mnt/c/vscode/vcart.me.novo/src/BCards.Web/Areas/Support/
├── Controllers/
│ ├── SupportController.cs
│ └── RatingsController.cs
├── Models/
├── Repositories/
├── Services/
├── ViewComponents/
└── Views/
Padrão de Controller com Area:
namespace BCards.Web.Areas.Support.Controllers;
[Area("Support")]
[Authorize]
public class SupportController : Controller
{
// ...
}
Bibliotecas Necessárias
| Biblioteca | QRRapido | BCards | Ação Necessária |
|---|---|---|---|
| Markdig | ✅ v0.37.0 | ❌ Não instalado | ➕ Adicionar |
| YamlDotNet | ✅ v16.2.0 | ❌ Não instalado | ➕ Adicionar |
| IMemoryCache | ✅ Built-in | ✅ Built-in | ✅ Já existe |
| HtmlAgilityPack | ❌ | ✅ v1.11.54 | ℹ️ Já tem (opcional) |
Comando de Instalação:
cd /mnt/c/vscode/vcart.me.novo/src/BCards.Web
dotnet add package Markdig
dotnet add package YamlDotNet
🏗️ Arquitetura Proposta: ASP.NET MVC Areas
Decisão Arquitetural: USAR AREAS
Justificativa:
- ✅ BCards já usa Areas (padrão estabelecido)
- ✅ Isolamento de código por contexto funcional
- ✅ Escalabilidade e manutenibilidade
- ✅ Evita conflitos de namespace
- ✅ Permite DI específico por área
- ✅ Estrutura organizacional clara
Estrutura Completa com Areas
/mnt/c/vscode/vcart.me.novo/src/BCards.Web/
├── Areas/
│ ├── Support/ # JÁ EXISTE (exemplo de referência)
│ │ ├── Controllers/
│ │ ├── Models/
│ │ ├── Services/
│ │ └── Views/
│ │
│ ├── Tutoriais/ # NOVA AREA
│ │ ├── Controllers/
│ │ │ └── TutoriaisController.cs
│ │ ├── Models/
│ │ │ ├── ArticleMetadata.cs
│ │ │ └── ViewModels/
│ │ │ └── ArticleViewModel.cs
│ │ ├── Services/
│ │ │ ├── IMarkdownService.cs
│ │ │ └── MarkdownService.cs
│ │ └── Views/
│ │ └── Tutoriais/
│ │ ├── Index.cshtml # Lista todas categorias
│ │ ├── Category.cshtml # Lista artigos da categoria
│ │ └── Article.cshtml # Artigo individual
│ │
│ └── Artigos/ # NOVA AREA
│ ├── Controllers/
│ │ └── ArtigosController.cs
│ ├── Models/
│ │ └── [compartilha com Tutoriais]
│ ├── Services/
│ │ └── [compartilha MarkdownService]
│ └── Views/
│ └── Artigos/
│ ├── Index.cshtml # Lista artigos inspiracionais
│ └── Article.cshtml # Artigo individual
│
└── Content/ # CONTEÚDO MARKDOWN
├── Tutoriais/
│ ├── tecnologia/
│ │ ├── como-criar-um-site.pt-BR.md
│ │ └── como-criar-um-site.es-PY.md (futuro)
│ ├── advocacia/
│ │ └── como-advogados-podem-usar-bcards.pt-BR.md
│ └── papelaria/
│ └── criando-cartoes-digitais.pt-BR.md
│
└── Artigos/
├── transformacao-digital.pt-BR.md
└── futuro-cartoes-digitais.pt-BR.md
🔧 Detalhes Técnicos de Implementação
1. Area "Tutoriais" - Controller
Path: /mnt/c/vscode/vcart.me.novo/src/BCards.Web/Areas/Tutoriais/Controllers/TutoriaisController.cs
using BCards.Web.Areas.Tutoriais.Models;
using BCards.Web.Areas.Tutoriais.Models.ViewModels;
using BCards.Web.Areas.Tutoriais.Services;
using BCards.Web.Repositories;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Areas.Tutoriais.Controllers;
[Area("Tutoriais")]
public class TutoriaisController : Controller
{
private readonly IMarkdownService _markdownService;
private readonly ICategoryRepository _categoryRepository; // JÁ EXISTE!
private readonly ILogger<TutoriaisController> _logger;
public TutoriaisController(
IMarkdownService markdownService,
ICategoryRepository categoryRepository,
ILogger<TutoriaisController> logger)
{
_markdownService = markdownService;
_categoryRepository = categoryRepository;
_logger = logger;
}
// GET /tutoriais
[HttpGet]
public async Task<IActionResult> Index()
{
var categories = await _categoryRepository.GetAllAsync();
var tutoriaisPorCategoria = new Dictionary<string, List<ArticleMetadata>>();
foreach (var category in categories)
{
var artigos = await _markdownService
.GetArticlesByCategoryAsync(category.Slug, "pt-BR");
if (artigos.Any())
{
tutoriaisPorCategoria[category.Slug] = artigos;
}
}
return View(tutoriaisPorCategoria);
}
// GET /tutoriais/{categoria}
[HttpGet("{categoria}")]
public async Task<IActionResult> Category(string categoria)
{
// Validar categoria existe
var category = await _categoryRepository.GetBySlugAsync(categoria);
if (category == null)
{
_logger.LogWarning("Categoria não encontrada: {Categoria}", categoria);
return NotFound();
}
var artigos = await _markdownService
.GetArticlesByCategoryAsync(categoria, "pt-BR");
ViewBag.Category = category;
return View(artigos);
}
// GET /tutoriais/{categoria}/{slug}
[HttpGet("{categoria}/{slug}")]
public async Task<IActionResult> Article(string categoria, string slug)
{
// Sanitização (segurança contra path traversal)
categoria = categoria.Replace("..", "").Replace("/", "").Replace("\\", "");
slug = slug.Replace("..", "").Replace("/", "").Replace("\\", "");
// Validar categoria existe
var category = await _categoryRepository.GetBySlugAsync(categoria);
if (category == null)
{
_logger.LogWarning("Categoria não encontrada: {Categoria}", categoria);
return NotFound();
}
try
{
var article = await _markdownService.GetArticleAsync(
$"Tutoriais/{categoria}/{slug}",
"pt-BR"
);
if (article == null)
{
_logger.LogWarning("Artigo não encontrado: {Categoria}/{Slug}", categoria, slug);
return NotFound();
}
// Buscar artigos relacionados da mesma categoria
article.RelatedArticles = await _markdownService
.GetArticlesByCategoryAsync(categoria, "pt-BR");
// Remover o artigo atual dos relacionados
article.RelatedArticles = article.RelatedArticles
.Where(a => a.Slug != slug)
.Take(3)
.ToList();
ViewBag.Category = category;
return View(article);
}
catch (FileNotFoundException)
{
_logger.LogWarning("Arquivo markdown não encontrado: {Categoria}/{Slug}", categoria, slug);
return NotFound();
}
}
}
2. Area "Artigos" - Controller
Path: /mnt/c/vscode/vcart.me.novo/src/BCards.Web/Areas/Artigos/Controllers/ArtigosController.cs
using BCards.Web.Areas.Tutoriais.Models.ViewModels; // Compartilha models
using BCards.Web.Areas.Tutoriais.Services; // Compartilha service
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Areas.Artigos.Controllers;
[Area("Artigos")]
public class ArtigosController : Controller
{
private readonly IMarkdownService _markdownService;
private readonly ILogger<ArtigosController> _logger;
public ArtigosController(
IMarkdownService markdownService,
ILogger<ArtigosController> logger)
{
_markdownService = markdownService;
_logger = logger;
}
// GET /artigos
[HttpGet]
public async Task<IActionResult> Index()
{
var artigos = await _markdownService
.GetAllArticlesAsync("Artigos", "pt-BR");
return View(artigos);
}
// GET /artigos/{slug}
[HttpGet("{slug}")]
public async Task<IActionResult> Article(string slug)
{
// Sanitização
slug = slug.Replace("..", "").Replace("/", "").Replace("\\", "");
try
{
var article = await _markdownService.GetArticleAsync(
$"Artigos/{slug}",
"pt-BR"
);
if (article == null)
{
_logger.LogWarning("Artigo não encontrado: {Slug}", slug);
return NotFound();
}
// Buscar outros artigos para "Leia também"
article.RelatedArticles = await _markdownService
.GetAllArticlesAsync("Artigos", "pt-BR");
article.RelatedArticles = article.RelatedArticles
.Where(a => a.Slug != slug)
.OrderByDescending(a => a.Date)
.Take(3)
.ToList();
return View(article);
}
catch (FileNotFoundException)
{
_logger.LogWarning("Arquivo markdown não encontrado: {Slug}", slug);
return NotFound();
}
}
}
3. MarkdownService (Compartilhado)
Path: /mnt/c/vscode/vcart.me.novo/src/BCards.Web/Areas/Tutoriais/Services/MarkdownService.cs
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 interface IMarkdownService
{
Task<ArticleViewModel?> GetArticleAsync(string relativePath, string culture);
Task<List<ArticleMetadata>> GetArticlesByCategoryAsync(string category, string culture);
Task<List<ArticleMetadata>> GetAllArticlesAsync(string baseFolder, string culture);
}
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);
}
}
}
4. Models
Path: /mnt/c/vscode/vcart.me.novo/src/BCards.Web/Areas/Tutoriais/Models/ArticleMetadata.cs
namespace BCards.Web.Areas.Tutoriais.Models;
public class ArticleMetadata
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Keywords { get; set; } = string.Empty;
public string Author { get; set; } = "BCards";
public DateTime Date { get; set; }
public DateTime LastMod { get; set; }
public string Image { get; set; } = string.Empty;
public string Culture { get; set; } = "pt-BR";
public string? Category { get; set; } // Apenas para tutoriais
public int ReadingTimeMinutes { get; set; }
public string Slug { get; set; } = string.Empty;
}
Path: /mnt/c/vscode/vcart.me.novo/src/BCards.Web/Areas/Tutoriais/Models/ViewModels/ArticleViewModel.cs
namespace BCards.Web.Areas.Tutoriais.Models.ViewModels;
public class ArticleViewModel
{
public ArticleMetadata Metadata { get; set; } = new();
public string HtmlContent { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public DateTime LastModified { get; set; }
public List<ArticleMetadata> RelatedArticles { get; set; } = new();
}
5. Configuração de Rotas (Program.cs)
Adicionar ANTES da rota default (linha ~790):
// ========================================
// AREAS: Tutoriais e Artigos
// ========================================
// Tutoriais Area - Artigo específico
app.MapAreaControllerRoute(
name: "tutoriais-article",
areaName: "Tutoriais",
pattern: "tutoriais/{categoria}/{slug}",
defaults: new { controller = "Tutoriais", action = "Article" },
constraints: new
{
categoria = @"^[a-z\-]+$", // slug de categoria
slug = @"^[a-z0-9\-]+$" // slug do artigo
});
// Tutoriais Area - Lista de artigos por categoria
app.MapAreaControllerRoute(
name: "tutoriais-category",
areaName: "Tutoriais",
pattern: "tutoriais/{categoria}",
defaults: new { controller = "Tutoriais", action = "Category" },
constraints: new { categoria = @"^[a-z\-]+$" });
// Tutoriais Area - Índice (todas categorias)
app.MapAreaControllerRoute(
name: "tutoriais-index",
areaName: "Tutoriais",
pattern: "tutoriais",
defaults: new { controller = "Tutoriais", action = "Index" });
// Artigos Area - Artigo específico
app.MapAreaControllerRoute(
name: "artigos-article",
areaName: "Artigos",
pattern: "artigos/{slug}",
defaults: new { controller = "Artigos", action = "Article" },
constraints: new { slug = @"^[a-z0-9\-]+$" });
// Artigos Area - Índice
app.MapAreaControllerRoute(
name: "artigos-index",
areaName: "Artigos",
pattern: "artigos",
defaults: new { controller = "Artigos", action = "Index" });
// ========================================
// Rota default (já existe)
// ========================================
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
6. Registro de DI (Program.cs)
Adicionar após os outros serviços (linha ~493):
// Markdown/Articles System - Tutoriais e Artigos Areas
builder.Services.AddScoped<
BCards.Web.Areas.Tutoriais.Services.IMarkdownService,
BCards.Web.Areas.Tutoriais.Services.MarkdownService>();
7. YAML Frontmatter - Estrutura
Tutoriais (Com Categoria):
Arquivo: Content/Tutoriais/tecnologia/como-criar-um-site.pt-BR.md
---
title: "Como Criar um Site Profissional com BCards"
description: "Aprenda passo a passo como criar um site profissional usando BCards e centralize todos os seus links em um único lugar"
keywords: "bcards, site, tutorial, tecnologia, cartão digital, links"
author: "BCards"
date: 2025-11-02
lastmod: 2025-11-02
image: "/images/tutoriais/tecnologia/criar-site-hero.jpg"
category: "tecnologia"
---
# Como Criar um Site Profissional com BCards
Neste tutorial, você aprenderá como criar um site profissional em poucos minutos...
## Passo 1: Cadastre-se no BCards
Lorem ipsum...
## Passo 2: Escolha seu Tema
Lorem ipsum...
## Conclusão
Agora você está pronto para...
Artigos (Sem Categoria):
Arquivo: Content/Artigos/transformacao-digital.pt-BR.md
---
title: "Transformação Digital para Pequenos Negócios"
description: "Descubra como a digitalização está revolucionando pequenas empresas e como você pode aproveitar essa transformação"
keywords: "transformação digital, pequenos negócios, inovação, tecnologia"
author: "BCards"
date: 2025-11-02
lastmod: 2025-11-02
image: "/images/artigos/transformacao-digital-hero.jpg"
---
# Transformação Digital para Pequenos Negócios
A transformação digital não é mais uma opção, mas uma necessidade...
🌍 Estratégia de Internacionalização (i18n)
Fase 1: Português (Atual) - IMPLEMENTAR AGORA
Content/Tutoriais/tecnologia/criar-site.pt-BR.md
Content/Artigos/transformacao-digital.pt-BR.md
Código:
// Hard-coded culture por enquanto
var culture = "pt-BR";
var article = await _markdownService.GetArticleAsync($"Tutoriais/{categoria}/{slug}", culture);
Fase 2: Múltiplos Idiomas (Futuro) - SEM REFATORAÇÃO
Apenas adicionar arquivos:
Content/Tutoriais/tecnologia/criar-site.pt-BR.md
Content/Tutoriais/tecnologia/crear-sitio.es-PY.md ← Novo
Roteamento (adaptação futura):
// Adicionar culture à rota
app.MapAreaControllerRoute(
name: "tutoriais-article-i18n",
areaName: "Tutoriais",
pattern: "{culture}/tutoriais/{categoria}/{slug}",
defaults: new { controller = "Tutoriais", action = "Article", culture = "pt-BR" },
constraints: new
{
culture = @"^(pt-BR|es-PY)$",
categoria = @"^[a-z\-]+$",
slug = @"^[a-z0-9\-]+$"
});
Arquitetura i18n-ready:
- ✅ Convenção de nomenclatura já suporta
- ✅ Middleware de localização já existe no BCards
- ✅ Sem mudanças na estrutura de pastas
- ✅ Sem refatoração de código
📈 Benefícios SEO
Meta Tags Automáticas (do YAML)
View: Areas/Tutoriais/Views/Tutoriais/Article.cshtml
@model BCards.Web.Areas.Tutoriais.Models.ViewModels.ArticleViewModel
@{
ViewData["Title"] = Model.Metadata.Title;
}
<!-- Meta Tags SEO -->
<meta name="description" content="@Model.Metadata.Description">
<meta name="keywords" content="@Model.Metadata.Keywords">
<meta name="author" content="@Model.Metadata.Author">
<meta name="robots" content="index, follow">
<link rel="canonical" href="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = ViewBag.Category.Slug, slug = Model.Slug }, Context.Request.Scheme)">
<!-- Open Graph -->
<meta property="og:type" content="article">
<meta property="og:title" content="@Model.Metadata.Title">
<meta property="og:description" content="@Model.Metadata.Description">
<meta property="og:image" content="@Model.Metadata.Image">
<meta property="og:url" content="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = ViewBag.Category.Slug, slug = Model.Slug }, Context.Request.Scheme)">
<meta property="article:published_time" content="@Model.Metadata.Date.ToString("yyyy-MM-ddTHH:mm:ssZ")">
<meta property="article:modified_time" content="@Model.Metadata.LastMod.ToString("yyyy-MM-ddTHH:mm:ssZ")">
<meta property="article:author" content="@Model.Metadata.Author">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="@Model.Metadata.Title">
<meta name="twitter:description" content="@Model.Metadata.Description">
<meta name="twitter:image" content="@Model.Metadata.Image">
<!-- Schema.org JSON-LD -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "Article",
"headline": "@Model.Metadata.Title",
"description": "@Model.Metadata.Description",
"image": "@Model.Metadata.Image",
"datePublished": "@Model.Metadata.Date.ToString("yyyy-MM-dd")",
"dateModified": "@Model.Metadata.LastMod.ToString("yyyy-MM-dd")",
"author": {
"@@type": "Person",
"name": "@Model.Metadata.Author"
},
"publisher": {
"@@type": "Organization",
"name": "BCards",
"logo": {
"@@type": "ImageObject",
"url": "https://bcards.site/logo.png"
}
}
}
</script>
<!-- BreadcrumbList Schema -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "BreadcrumbList",
"itemListElement": [
{
"@@type": "ListItem",
"position": 1,
"name": "Início",
"item": "https://bcards.site"
},
{
"@@type": "ListItem",
"position": 2,
"name": "Tutoriais",
"item": "https://bcards.site/tutoriais"
},
{
"@@type": "ListItem",
"position": 3,
"name": "@ViewBag.Category.Name",
"item": "https://bcards.site/tutoriais/@ViewBag.Category.Slug"
},
{
"@@type": "ListItem",
"position": 4,
"name": "@Model.Metadata.Title"
}
]
}
</script>
Sitemap.xml Integration
Adicionar no HomeController.cs (método Sitemap()):
// Tutoriais
var markdownService = _serviceProvider.GetRequiredService<
BCards.Web.Areas.Tutoriais.Services.IMarkdownService>();
var categories = await _categoryRepository.GetAllAsync();
foreach (var category in categories)
{
var tutoriais = await markdownService.GetArticlesByCategoryAsync(category.Slug, "pt-BR");
foreach (var tutorial in tutoriais)
{
sitemap.Append($@"
<url>
<loc>https://bcards.site/tutoriais/{category.Slug}/{tutorial.Slug}</loc>
<lastmod>{tutorial.LastMod:yyyy-MM-dd}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>");
}
}
// Artigos
var artigos = await markdownService.GetAllArticlesAsync("Artigos", "pt-BR");
foreach (var artigo in artigos)
{
sitemap.Append($@"
<url>
<loc>https://bcards.site/artigos/{artigo.Slug}</loc>
<lastmod>{artigo.LastMod:yyyy-MM-dd}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>");
}
⚡ Performance e Cache
Sistema de Cache
// MarkdownService.cs
private readonly IMemoryCache _cache;
public async Task<ArticleViewModel> GetArticleAsync(string slug, string culture)
{
var cacheKey = $"Article_{slug}_{culture}";
if (!_cache.TryGetValue(cacheKey, out ArticleViewModel article))
{
article = await LoadAndProcessMarkdownAsync(slug, culture);
_cache.Set(cacheKey, article, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
});
}
return article;
}
Performance Esperada:
- 🔥 Cold hit (primeiro acesso): ~50-100ms
- ⚡ Warm hit (cached): <1ms
- 📦 Cache hit rate: 95%+
Cache HTTP (Middleware)
Adicionar no Program.cs middleware de cache:
app.Use(async (context, next) =>
{
// Artigos/Tutoriais - cache público moderado
if (context.Request.Path.StartsWithSegments("/tutoriais") ||
context.Request.Path.StartsWithSegments("/artigos"))
{
context.Response.Headers.Append("Cache-Control", "public, max-age=3600"); // 1 hora
context.Response.Headers.Append("Vary", "Accept-Encoding");
}
await next();
});
🔒 Segurança
1. Validação de Input (Path Traversal)
// TutoriaisController.cs
public async Task<IActionResult> Article(string categoria, string slug)
{
// Sanitização - previne ataques de path traversal
categoria = categoria.Replace("..", "").Replace("/", "").Replace("\\", "");
slug = slug.Replace("..", "").Replace("/", "").Replace("\\", "");
// Validação contra slug malicioso via regex (rotas já validam, mas double-check)
if (!Regex.IsMatch(categoria, @"^[a-z\-]+$") ||
!Regex.IsMatch(slug, @"^[a-z0-9\-]+$"))
{
return BadRequest("Parâmetros inválidos");
}
}
2. XSS Protection (Markdig)
// MarkdownService.cs - Pipeline configurado para segurança
_markdownPipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.DisableHtml() // CRÍTICO: Bloqueia HTML inline perigoso
.Build();
Comportamento:
<!-- Arquivo .md (tentativa de XSS) -->
<script>alert('XSS')</script>
Este é um texto seguro
<!-- Output HTML renderizado -->
<script>alert('XSS')</script>
<p>Este é um texto seguro</p>
3. File System Access Control
// MarkdownService.cs
private readonly string _contentBasePath;
public MarkdownService(IWebHostEnvironment environment)
{
// Path base restrito à pasta Content/
_contentBasePath = Path.Combine(environment.ContentRootPath, "Content");
}
public async Task<ArticleViewModel?> GetArticleAsync(string relativePath, string culture)
{
// Sempre combina com _contentBasePath (não permite acesso fora de Content/)
var fullPath = Path.Combine(_contentBasePath, $"{relativePath}.{culture}.md");
// Double-check: verifica se path final está dentro de Content/
if (!fullPath.StartsWith(_contentBasePath))
{
_logger.LogWarning("Tentativa de acesso fora de Content/: {Path}", fullPath);
return null;
}
}
📝 Plano de Implementação (4-6 horas)
Fase 1: Setup e Pacotes (30 min)
- Instalar Markdig + YamlDotNet
- Criar estrutura de pastas Areas (Tutoriais + Artigos)
- Criar estrutura Content (Tutoriais/{categorias} + Artigos)
Fase 2: Models e Services (1.5h)
- Criar
ArticleMetadata.cs - Criar
ArticleViewModel.cs - Criar
IMarkdownService.csinterface - Implementar
MarkdownService.cscompleto - Registrar DI em
Program.cs
Fase 3: Controllers (1h)
- Criar
TutoriaisController.cs(3 actions) - Criar
ArtigosController.cs(2 actions) - Adicionar rotas no
Program.cs
Fase 4: Views (1.5h)
- Criar
Tutoriais/Index.cshtml - Criar
Tutoriais/Category.cshtml - Criar
Tutoriais/Article.cshtml - Criar
Artigos/Index.cshtml - Criar
Artigos/Article.cshtml - Adaptar CSS para identidade visual BCards
Fase 5: SEO e Integração (45 min)
- Adicionar meta tags nas views
- Implementar Schema.org JSON-LD
- Integrar no
Sitemap.xml(HomeController) - Configurar cache HTTP no middleware
Fase 6: Conteúdo e Testes (45 min)
- Criar 2-3 tutoriais de exemplo
- Criar 1-2 artigos inspiracionais
- Testar todas rotas
- Validar cache (warm/cold hits)
- Verificar SEO tags (View Source)
- Testar segurança (path traversal)
✅ Checklist Completo de Arquivos
Criar (23 arquivos novos)
Content (Markdown)
📁 Content/
📁 Tutoriais/
📁 tecnologia/
📄 como-criar-um-site.pt-BR.md
📄 guia-completo-bcards.pt-BR.md
📁 advocacia/
📄 como-advogados-podem-usar-bcards.pt-BR.md
📁 papelaria/
📄 criando-cartoes-digitais.pt-BR.md
📁 Artigos/
📄 transformacao-digital.pt-BR.md
📄 futuro-cartoes-digitais.pt-BR.md
Area Tutoriais
📁 src/BCards.Web/Areas/Tutoriais/
📁 Controllers/
📄 TutoriaisController.cs
📁 Models/
📄 ArticleMetadata.cs
📁 ViewModels/
📄 ArticleViewModel.cs
📁 Services/
📄 IMarkdownService.cs
📄 MarkdownService.cs
📁 Views/
📁 Tutoriais/
📄 Index.cshtml
📄 Category.cshtml
📄 Article.cshtml
Area Artigos
📁 src/BCards.Web/Areas/Artigos/
📁 Controllers/
📄 ArtigosController.cs
📁 Views/
📁 Artigos/
📄 Index.cshtml
📄 Article.cshtml
Modificar (3 arquivos existentes)
📄 src/BCards.Web/BCards.Web.csproj → Adicionar Markdig + YamlDotNet
📄 src/BCards.Web/Program.cs → Registrar DI + Rotas de Areas
📄 src/BCards.Web/Controllers/HomeController.cs → Adicionar artigos ao sitemap (opcional)
💰 Estimativa de Custo/Benefício
Custo
- Desenvolvimento: 4-6 horas
- NuGet Packages: Gratuitos (Markdig + YamlDotNet são open-source)
- Infraestrutura: Zero (usa filesystem, não requer DB)
- Manutenção: Baixíssima (apenas criar novos arquivos .md)
Benefício
- ✅ SEO: Conteúdo indexável aumenta tráfego orgânico (+30-50% estimado)
- ✅ Educação: Reduz suporte via tutoriais self-service (-20% tickets)
- ✅ Credibilidade: Demonstra expertise no setor
- ✅ Conversão: Artigos podem incluir CTAs para planos Premium (+10% conversão)
- ✅ i18n-ready: Expansão internacional facilitada (sem refatoração)
- ✅ Escalabilidade: Adicionar artigos é trivial (apenas criar .md)
ROI Estimado: Alto (custo mínimo, benefícios de longo prazo)
🚨 Riscos e Mitigações
| Risco | Probabilidade | Impacto | Mitigação |
|---|---|---|---|
| Conflito de rotas | Baixa | Médio | Usar /tutoriais e /artigos (não conflita com /page) + Constraints em rotas |
| Performance (muitos .md) | Baixa | Baixo | Cache de 1 hora resolve + Lazy loading |
| XSS via Markdown | Média | Alto | DisableHtml() no Markdig pipeline |
| Path traversal | Baixa | Alto | Sanitização de input + Path validation |
| Namespace conflicts | Muito Baixa | Baixo | Areas isola namespaces automaticamente |
| Categoria inválida | Média | Baixo | Validação via CategoryRepository existente |
🎯 Recomendações Finais
Priorização
IMPLEMENTAR AGORA (Alta Prioridade):
- ✅ Area "Tutoriais" completa (
/tutoriais/{categoria}/{slug}) - ✅ Integração com
CategoryRepositoryexistente - ✅ SEO básico (meta tags, sitemap, schema.org)
- ✅ Cache de 1 hora (IMemoryCache)
IMPLEMENTAR EM SEGUIDA (Média Prioridade):
5. ⏳ Area "Artigos" (/artigos/{slug})
6. ⏳ Criar 5-10 tutoriais iniciais
7. ⏳ Analytics de leitura (Google Analytics events)
IMPLEMENTAR DEPOIS (Baixa Prioridade): 8. 💭 Múltiplos idiomas (pt-BR, es-PY) 9. 💭 Sistema de comentários 10. 💭 CMS web para edição (por enquanto usar VS Code) 11. 💭 Search/filtros avançados
Evolução Natural
Fase 1 (Agora - Semana 1):
- Português apenas
- Filesystem-based
- 10-15 artigos iniciais
Fase 2 (Mês 2-3):
- Adicionar espanhol (apenas novos
.md) - 30-50 artigos
- Analytics de leitura
Fase 3 (Mês 4-6):
- Search interno
- Filtros por categoria
- Artigos relacionados mais inteligentes
Fase 4 (Ano 1+):
- CMS web opcional
- Múltiplos autores
- Sistema de versioning
📚 Referências
Código QRRapido (Base)
- Services:
/mnt/c/vscode/qrrapido/Services/MarkdownService.cs - Controllers:
/mnt/c/vscode/qrrapido/Controllers/TutoriaisController.cs - Views:
/mnt/c/vscode/qrrapido/Views/Tutoriais/
BCards (Integração)
- Docs:
/mnt/c/vscode/vcart.me.novo/CLAUDE.md - Categories:
/mnt/c/vscode/vcart.me.novo/BCardsDB_Dev.categories.json - Area Example:
/mnt/c/vscode/vcart.me.novo/src/BCards.Web/Areas/Support/ - Repository:
/mnt/c/vscode/vcart.me.novo/src/BCards.Web/Repositories/CategoryRepository.cs - Program.cs:
/mnt/c/vscode/vcart.me.novo/src/BCards.Web/Program.cs
Bibliotecas
- Markdig: https://github.com/xoofx/markdig
- YamlDotNet: https://github.com/aaubry/YamlDotNet
- ASP.NET Areas: https://learn.microsoft.com/en-us/aspnet/core/mvc/controllers/areas
🏁 Conclusão
A implementação de um sistema de artigos no BCards usando ASP.NET MVC Areas é 100% viável e apresenta compatibilidade arquitetural perfeita com o código existente.
Pontos Fortes
✅ Areas já em uso: BCards usa Areas (exemplo: Support) ✅ Arquitetura MVC + Service + Repository: Padrão estabelecido ✅ CategoryRepository existente: Integração natural com categorias ✅ Bootstrap 5: Compatível com views do QRRapido ✅ IMemoryCache: Sistema de cache pronto ✅ i18n-ready: Expansão internacional sem refatoração ✅ Isolamento: Areas separa código de forma limpa ✅ Escalabilidade: Fácil adicionar novas features por área
Diferencial: Areas
O uso de ASP.NET MVC Areas traz vantagens significativas:
- Organização: Código isolado por contexto (Tutoriais, Artigos, Support)
- Namespaces: Zero conflitos de nomes
- Manutenibilidade: Equipes diferentes podem trabalhar em areas diferentes
- Padrão estabelecido: Já usado no BCards (consistência)
Próximos Passos
- ✅ Aprovação do usuário
- Instalação de pacotes NuGet (2 minutos)
- Criação de estrutura de Areas (30 minutos)
- Implementação de Services e Controllers (3 horas)
- Criação de Views (1.5 horas)
- Criação de 2-3 artigos de teste (30 minutos)
- Deploy e validação
Recomendação: Prosseguir com implementação usando ASP.NET MVC Areas. A arquitetura é sólida, escalável e perfeitamente alinhada com os padrões do BCards.
Documento aprovado para implementação ✅