Renders the markdown documents in Content/Socialism: a landing overview at
/socialism and chapters at /socialism/{slug}, with a sectioned sidebar,
on-page table of contents, prev/next paging and cross-document links.
Pages are noindex and nothing on the site links to them. Underscore-prefixed
files (founding draft, progress log) are never served, deployed or committed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
179 lines
6.0 KiB
C#
179 lines
6.0 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text.RegularExpressions;
|
|
using JoshHeaps.Net.Models;
|
|
using JoshHeaps.Net.Services.Interfaces;
|
|
using Markdig;
|
|
using Markdig.Renderers.Html;
|
|
using Markdig.Syntax;
|
|
using Markdig.Syntax.Inlines;
|
|
|
|
namespace JoshHeaps.Net.Services.Implementations;
|
|
|
|
/// <summary>
|
|
/// Serves the markdown documents in Content/Socialism. Files starting with an underscore
|
|
/// (the founding draft, the progress log) are private and never served.
|
|
/// </summary>
|
|
public partial class SocialismDocService : ISocialismDocService
|
|
{
|
|
public const string IndexSlug = "index";
|
|
|
|
private readonly string _contentDir;
|
|
private readonly ILogger<SocialismDocService> _logger;
|
|
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
|
|
|
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
|
|
.UseAdvancedExtensions()
|
|
.Build();
|
|
|
|
public SocialismDocService(IWebHostEnvironment env, ILogger<SocialismDocService> logger)
|
|
{
|
|
_contentDir = Path.Combine(env.ContentRootPath, "Content", "Socialism");
|
|
_logger = logger;
|
|
}
|
|
|
|
public SocialismDoc? GetDoc(string slug)
|
|
{
|
|
if (!IsValidSlug(slug))
|
|
return null;
|
|
|
|
var path = Path.Combine(_contentDir, slug + ".md");
|
|
if (!File.Exists(path))
|
|
return null;
|
|
|
|
// Cache keyed on last-write time so edited documents show up without a restart.
|
|
var lastWrite = File.GetLastWriteTimeUtc(path);
|
|
if (_cache.TryGetValue(slug, out var entry) && entry.LastWrite == lastWrite)
|
|
return entry.Doc;
|
|
|
|
try
|
|
{
|
|
var doc = Parse(slug, File.ReadAllText(path));
|
|
_cache[slug] = new CacheEntry(doc, lastWrite);
|
|
return doc;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to render socialism doc {Slug}", slug);
|
|
return entry?.Doc;
|
|
}
|
|
}
|
|
|
|
public List<SocialismDoc> GetAllDocs()
|
|
{
|
|
if (!Directory.Exists(_contentDir))
|
|
return [];
|
|
|
|
return Directory.EnumerateFiles(_contentDir, "*.md")
|
|
.Select(Path.GetFileNameWithoutExtension)
|
|
.Where(slug => slug is not null && IsValidSlug(slug))
|
|
.Select(slug => GetDoc(slug!))
|
|
.OfType<SocialismDoc>()
|
|
.OrderBy(d => d.Slug == IndexSlug ? 0 : 1)
|
|
.ThenBy(d => d.Order)
|
|
.ThenBy(d => d.Title)
|
|
.ToList();
|
|
}
|
|
|
|
// Lowercase letters, digits and hyphens only: rules out private "_" files and path traversal.
|
|
private static bool IsValidSlug(string slug) => SlugRegex().IsMatch(slug);
|
|
|
|
private static SocialismDoc Parse(string slug, string raw)
|
|
{
|
|
var (meta, body) = SplitFrontMatter(raw);
|
|
var markdown = Markdown.Parse(body, Pipeline);
|
|
|
|
var headings = new List<SocialismHeading>();
|
|
string? firstH1 = null;
|
|
foreach (var heading in markdown.Descendants<HeadingBlock>())
|
|
{
|
|
var text = InlineText(heading.Inline);
|
|
if (heading.Level == 1)
|
|
{
|
|
firstH1 ??= text;
|
|
continue;
|
|
}
|
|
|
|
var id = heading.GetAttributes().Id;
|
|
if (heading.Level <= 3 && !string.IsNullOrEmpty(id))
|
|
headings.Add(new SocialismHeading(heading.Level, id, text));
|
|
}
|
|
|
|
foreach (var link in markdown.Descendants<LinkInline>())
|
|
{
|
|
if (!link.IsImage && link.Url is not null)
|
|
link.Url = RewriteLink(link.Url);
|
|
}
|
|
|
|
var words = WordRegex().Matches(body).Count;
|
|
|
|
return new SocialismDoc
|
|
{
|
|
Slug = slug,
|
|
Title = meta.GetValueOrDefault("title") ?? firstH1 ?? slug,
|
|
Summary = meta.GetValueOrDefault("summary") ?? string.Empty,
|
|
Section = meta.GetValueOrDefault("section") ?? string.Empty,
|
|
Order = int.TryParse(meta.GetValueOrDefault("order"), out var order) ? order : int.MaxValue,
|
|
HtmlContent = markdown.ToHtml(Pipeline),
|
|
Headings = headings,
|
|
ReadingMinutes = Math.Max(1, (int)Math.Round(words / 230.0))
|
|
};
|
|
}
|
|
|
|
// "economy.md#floor" -> "/socialism/economy#floor"; "index.md" -> "/socialism".
|
|
private static string RewriteLink(string url)
|
|
{
|
|
var match = RelativeDocLinkRegex().Match(url);
|
|
if (!match.Success)
|
|
return url;
|
|
|
|
var target = match.Groups["slug"].Value;
|
|
var anchor = match.Groups["anchor"].Value;
|
|
return (target == IndexSlug ? "/socialism" : $"/socialism/{target}") + anchor;
|
|
}
|
|
|
|
private static (Dictionary<string, string> Meta, string Body) SplitFrontMatter(string raw)
|
|
{
|
|
var meta = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
var match = FrontMatterRegex().Match(raw);
|
|
if (!match.Success)
|
|
return (meta, raw);
|
|
|
|
foreach (var line in match.Groups["yaml"].Value.Split('\n'))
|
|
{
|
|
var idx = line.IndexOf(':');
|
|
if (idx <= 0)
|
|
continue;
|
|
meta[line[..idx].Trim()] = line[(idx + 1)..].Trim().Trim('"');
|
|
}
|
|
|
|
return (meta, raw[match.Length..]);
|
|
}
|
|
|
|
private static string InlineText(ContainerInline? inline)
|
|
{
|
|
if (inline is null)
|
|
return string.Empty;
|
|
|
|
return string.Concat(inline.Descendants<Inline>().Select(i => i switch
|
|
{
|
|
LiteralInline literal => literal.Content.ToString(),
|
|
CodeInline code => code.Content,
|
|
_ => string.Empty
|
|
}));
|
|
}
|
|
|
|
[GeneratedRegex("^[a-z0-9][a-z0-9-]*$")]
|
|
private static partial Regex SlugRegex();
|
|
|
|
[GeneratedRegex(@"^(?:\./)?(?<slug>[a-z0-9][a-z0-9-]*)\.md(?<anchor>#[^\s]*)?$")]
|
|
private static partial Regex RelativeDocLinkRegex();
|
|
|
|
[GeneratedRegex(@"\A---\r?\n(?<yaml>.*?)\r?\n---\r?\n", RegexOptions.Singleline)]
|
|
private static partial Regex FrontMatterRegex();
|
|
|
|
[GeneratedRegex(@"\b\w+\b")]
|
|
private static partial Regex WordRegex();
|
|
|
|
private record CacheEntry(SocialismDoc Doc, DateTime LastWrite);
|
|
}
|