using Microsoft.AspNetCore.Hosting; using PlotLine.Models; namespace PlotLine.Services; public interface IHelpService { IReadOnlyList GetAllArticles(); HelpArticle? GetByContextKey(string contextKey); HelpArticle? GetByRelativePath(string relativePath); IReadOnlyList GetCoverage(); } public sealed class HelpService(IWebHostEnvironment environment) : IHelpService { private readonly object cacheLock = new(); private IReadOnlyList? cachedArticles; public IReadOnlyList GetAllArticles() { if (cachedArticles is not null) { return cachedArticles; } lock (cacheLock) { cachedArticles ??= LoadArticles(); return cachedArticles; } } public HelpArticle? GetByContextKey(string contextKey) { if (string.IsNullOrWhiteSpace(contextKey)) { return null; } return GetAllArticles().FirstOrDefault(x => string.Equals(x.ContextKey, contextKey, StringComparison.OrdinalIgnoreCase)); } public HelpArticle? GetByRelativePath(string relativePath) { if (string.IsNullOrWhiteSpace(relativePath)) { return null; } var normalisedPath = relativePath.Replace('\\', '/'); return GetAllArticles().FirstOrDefault(x => string.Equals(x.RelativePath, normalisedPath, StringComparison.OrdinalIgnoreCase)); } public IReadOnlyList GetCoverage() => GetAllArticles() .Select(x => new HelpCoverageItem { ContextKey = x.ContextKey, FilePath = x.RelativePath, IsValid = x.IsValid, ValidationIssues = x.ValidationIssues }) .OrderBy(x => x.ContextKey) .ThenBy(x => x.FilePath) .ToList(); private IReadOnlyList LoadArticles() { var articleRoot = Path.Combine(environment.ContentRootPath, "Help", "Articles"); if (!Directory.Exists(articleRoot)) { return []; } return Directory .EnumerateFiles(articleRoot, "*.md", SearchOption.AllDirectories) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .Select(path => ParseArticle(articleRoot, path)) .ToList(); } private static HelpArticle ParseArticle(string articleRoot, string filePath) { var rawContent = File.ReadAllText(filePath); var relativePath = Path.GetRelativePath(articleRoot, filePath).Replace('\\', '/'); var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); var markdownContent = rawContent; var issues = new List(); if (rawContent.StartsWith("---", StringComparison.Ordinal)) { using var reader = new StringReader(rawContent); _ = reader.ReadLine(); var frontMatterLines = new List(); string? line; var frontMatterClosed = false; while ((line = reader.ReadLine()) is not null) { if (line.Trim() == "---") { frontMatterClosed = true; break; } frontMatterLines.Add(line); } if (frontMatterClosed) { metadata = ParseFrontMatter(frontMatterLines); markdownContent = reader.ReadToEnd().TrimStart(); } else { issues.Add("Front matter was opened but not closed."); } } else { issues.Add("Front matter is missing."); } var article = new HelpArticle { Title = GetValue(metadata, "title"), Category = GetValue(metadata, "category"), Tags = SplitTags(GetValue(metadata, "tags")), ContextKey = GetValue(metadata, "contextKey"), Micro = GetValue(metadata, "micro"), QuickTitle = GetValue(metadata, "quickTitle"), QuickSummary = GetValue(metadata, "quickSummary"), Screenshot = GetValue(metadata, "screenshot"), MarkdownContent = markdownContent, RelativePath = relativePath }; issues.AddRange(Validate(article)); article.ValidationIssues = issues; return article; } private static Dictionary ParseFrontMatter(IEnumerable lines) { var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var line in lines) { var separatorIndex = line.IndexOf(':'); if (separatorIndex <= 0) { continue; } var key = line[..separatorIndex].Trim(); var value = line[(separatorIndex + 1)..].Trim().Trim('"'); metadata[key] = value; } return metadata; } private static IReadOnlyList Validate(HelpArticle article) { var issues = new List(); if (string.IsNullOrWhiteSpace(article.Title)) issues.Add("Title is required."); if (string.IsNullOrWhiteSpace(article.ContextKey)) issues.Add("ContextKey is required."); if (string.IsNullOrWhiteSpace(article.QuickTitle)) issues.Add("QuickTitle is required."); if (string.IsNullOrWhiteSpace(article.QuickSummary)) issues.Add("QuickSummary is required."); return issues; } private static string GetValue(IReadOnlyDictionary metadata, string key) => metadata.TryGetValue(key, out var value) ? value : string.Empty; private static IReadOnlyList SplitTags(string tags) => string.IsNullOrWhiteSpace(tags) ? [] : tags.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); }