Files
JoshHeaps.Net/JoshHeaps.Net/Services/Implementations/SocialismDocService.cs
T
jheapsandClaude Opus 5.5 e8f930e9fc Add unlisted /socialism pages for the worker-ownership model
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]>
2026-09-23 03:10:35 -06:00

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);
}