From 04297f25273e124effd1295efc86ac74a66dfb24 Mon Sep 17 00:00:00 2001 From: XtremeOwnage <5262735+XtremeOwnageDotCom@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:01:05 -0500 Subject: [PATCH 001/142] Initial commit --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ae47789 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 XtremeOwnage + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From e93117caa0b53db0c512cf5bcaea4b1271fe460e Mon Sep 17 00:00:00 2001 From: Netdocs Date: Mon, 20 Jul 2026 17:43:32 -0500 Subject: [PATCH 002/142] Add client-side syntax highlighting (highlight.js) for code blocks --- .../templates/main.html | 2 ++ .../templates/partials/highlight.html | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/Netdocs.Theme.Material/templates/partials/highlight.html diff --git a/src/Netdocs.Theme.Material/templates/main.html b/src/Netdocs.Theme.Material/templates/main.html index 6240ee8..29675d6 100644 --- a/src/Netdocs.Theme.Material/templates/main.html +++ b/src/Netdocs.Theme.Material/templates/main.html @@ -67,6 +67,7 @@ {{ if !is_homepage && page.title != "" }}{{ page.title }} - {{ end }}{{ config.site_name }} + @@ -143,6 +144,7 @@ {{~ for src in scripts ~}} {{~ end ~}} + {{ include "partials/highlight.html" }} {{ include "partials/mermaid.html" }} diff --git a/src/Netdocs.Theme.Material/templates/partials/highlight.html b/src/Netdocs.Theme.Material/templates/partials/highlight.html new file mode 100644 index 0000000..8ac3759 --- /dev/null +++ b/src/Netdocs.Theme.Material/templates/partials/highlight.html @@ -0,0 +1,20 @@ + + + From 703ddf95e4409ba80d9ccec2f9766266eb3ad116 Mon Sep 17 00:00:00 2001 From: Netdocs Date: Mon, 20 Jul 2026 17:45:57 -0500 Subject: [PATCH 003/142] Add built-in sitemap.xml and redirects plugin (fixes /discord) --- src/Netdocs.Cli/CliApp.cs | 1 + src/Netdocs.Core/BuildEngine.cs | 22 ++++++++++++ src/Netdocs.Plugins/RedirectsPlugin.cs | 47 ++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 src/Netdocs.Plugins/RedirectsPlugin.cs diff --git a/src/Netdocs.Cli/CliApp.cs b/src/Netdocs.Cli/CliApp.cs index 3c48292..91b699a 100644 --- a/src/Netdocs.Cli/CliApp.cs +++ b/src/Netdocs.Cli/CliApp.cs @@ -95,6 +95,7 @@ private static (SiteConfig, BuildOptions) LoadConfig(string configPath, CliOptio .Register("meta") .Register("rss") .Register("glightbox") + .Register("redirects") .Register("git-revision-date-localized") .Register("file-filter") .Register("social") diff --git a/src/Netdocs.Core/BuildEngine.cs b/src/Netdocs.Core/BuildEngine.cs index 98dce63..03ee7ff 100644 --- a/src/Netdocs.Core/BuildEngine.cs +++ b/src/Netdocs.Core/BuildEngine.cs @@ -136,11 +136,33 @@ public async Task BuildAsync(CancellationToken ct = default) await hook.OnBuildCompleteAsync(site, ct); } + // 12. Built-in sitemap.xml. + await EmitSitemapAsync(site, ct); + sw.Stop(); _log.LogInformation("Built {Count} pages in {Ms} ms", site.Pages.Count, sw.ElapsedMilliseconds); return site; } + private async Task EmitSitemapAsync(SiteContext site, CancellationToken ct) + { + var baseUrl = (config.SiteUrl ?? "").TrimEnd('/'); + var sb = new System.Text.StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + foreach (var page in site.Pages.OrderBy(p => p.Url, StringComparer.Ordinal)) + { + var loc = baseUrl.Length > 0 ? $"{baseUrl}/{page.Url}" : "/" + page.Url; + sb.Append(" ").Append(System.Security.SecurityElement.Escape(loc)).Append(""); + if (page.Updated is { } updated) + sb.Append("").Append(updated.ToString("yyyy-MM-dd")).Append(""); + sb.AppendLine(""); + } + sb.AppendLine(""); + await File.WriteAllTextAsync(Path.Combine(config.AbsoluteSiteDir, "sitemap.xml"), sb.ToString(), ct); + _log.LogDebug("Wrote sitemap.xml ({Count} urls)", site.Pages.Count); + } + /// Merges config plugins with plugins backed by markdown_extensions (e.g. snippets). private List BuildEffectivePluginList() { diff --git a/src/Netdocs.Plugins/RedirectsPlugin.cs b/src/Netdocs.Plugins/RedirectsPlugin.cs new file mode 100644 index 0000000..586c72c --- /dev/null +++ b/src/Netdocs.Plugins/RedirectsPlugin.cs @@ -0,0 +1,47 @@ +using Netdocs.Abstractions; + +namespace Netdocs.Plugins; + +/// Emits client-side redirect pages from a redirect_maps option (source path -> target URL). +public sealed class RedirectsPlugin : IPlugin, IBuildHook +{ + private IReadOnlyDictionary _maps = new Dictionary(); + + public string Name => "redirects"; + + public void Configure(IPluginContext ctx) + { + if (ctx.PluginOptions.TryGetValue("redirect_maps", out var m) && m is IReadOnlyDictionary map) + _maps = map; + } + + public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct) + { + foreach (var (source, targetObj) in _maps) + { + var target = targetObj?.ToString(); + if (string.IsNullOrWhiteSpace(target)) continue; + + var relative = source.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ? source[..^3] + "/index.html" + : source.TrimEnd('/') + "/index.html"; + var dest = Path.Combine(site.Config.AbsoluteSiteDir, relative.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + + var escaped = System.Net.WebUtility.HtmlEncode(target); + var html = $""" + + + + + Redirecting… + + + + Redirecting to {escaped}… + + """; + await File.WriteAllTextAsync(dest, html, ct); + } + } +} From c1dcd45429f49fc73e712693c87fe869092c405a Mon Sep 17 00:00:00 2001 From: Netdocs Date: Mon, 20 Jul 2026 17:47:49 -0500 Subject: [PATCH 004/142] Render tag index into pages with marker --- src/Netdocs.Plugins/TagsPlugin.cs | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/Netdocs.Plugins/TagsPlugin.cs b/src/Netdocs.Plugins/TagsPlugin.cs index 5c7cb85..37ef20a 100644 --- a/src/Netdocs.Plugins/TagsPlugin.cs +++ b/src/Netdocs.Plugins/TagsPlugin.cs @@ -50,9 +50,45 @@ public Task OnBuildStartAsync(SiteContext site, CancellationToken ct) } site.State["tags"] = index; + RenderTagIndex(site, index); return Task.CompletedTask; } + /// Replaces the <!-- material/tags --> marker with a rendered tag index. + private static void RenderTagIndex(SiteContext site, SortedDictionary> index) + { + const string marker = ""; + var markdown = new System.Text.StringBuilder(); + foreach (var (tag, pages) in index) + { + markdown.Append("## ").AppendLine(tag).AppendLine(); + foreach (var page in pages.DistinctBy(p => p.Url).OrderBy(GetDisplayTitle, StringComparer.OrdinalIgnoreCase)) + markdown.Append("- [").Append(GetDisplayTitle(page)).Append("](/").Append(page.Url).AppendLine(")"); + markdown.AppendLine(); + } + + foreach (var page in site.Pages) + { + if (page.RawMarkdown.Contains(marker, StringComparison.Ordinal)) + page.RawMarkdown = page.RawMarkdown.Replace(marker, markdown.ToString()); + } + } + + /// Title known before render: front matter, else first H1 in markdown, else filename. + private static string GetDisplayTitle(Page page) + { + if (!string.IsNullOrEmpty(page.Title)) return page.Title; + if (page.FrontMatter.TryGetValue("title", out var t) && t is string ft && ft.Length > 0) return ft; + foreach (var line in page.RawMarkdown.Split('\n')) + { + var trimmed = line.TrimStart(); + if (trimmed.StartsWith("# ", StringComparison.Ordinal)) + return trimmed[2..].Trim(); + } + var name = System.IO.Path.GetFileNameWithoutExtension(page.RelativePath); + return name.Replace('-', ' ').Replace('_', ' '); + } + public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct) { if (!_export || site.State["tags"] is not SortedDictionary> index) return; From 9b3efcc559a7d29a56801e0c71bd04fcd87b8367 Mon Sep 17 00:00:00 2001 From: Netdocs Date: Mon, 20 Jul 2026 17:54:37 -0500 Subject: [PATCH 005/142] Hierarchical tag index, page date footer, and real git-revision dates - Tag index nests by '/' hierarchy (parent -> child heading levels) - Content pages show last-updated/created footer (non-generated only) - GitRevisionDatePlugin: real dates via single-pass LibGit2Sharp history walk (filesystem fallback; skipped during serve for fast rebuilds) --- Directory.Packages.props | 1 + src/Netdocs.Core/PageRenderer.cs | 3 + src/Netdocs.Plugins/GitRevisionDatePlugin.cs | 99 +++++++++++++++++++ src/Netdocs.Plugins/Netdocs.Plugins.csproj | 1 + src/Netdocs.Plugins/StubPlugins.cs | 18 ---- src/Netdocs.Plugins/TagsPlugin.cs | 46 +++++++-- .../templates/main.html | 10 ++ 7 files changed, 154 insertions(+), 24 deletions(-) create mode 100644 src/Netdocs.Plugins/GitRevisionDatePlugin.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 41ca33c..485f9f9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,7 @@ + diff --git a/src/Netdocs.Core/PageRenderer.cs b/src/Netdocs.Core/PageRenderer.cs index a0f3b65..858d540 100644 --- a/src/Netdocs.Core/PageRenderer.cs +++ b/src/Netdocs.Core/PageRenderer.cs @@ -35,6 +35,9 @@ public static string Render(TemplateEngine engine, SiteContext site, Page page, ["palette_accent"] = Sanitize(palette?.Accent ?? "indigo"), ["font_text"] = font.TryGetValue("text", out var ft) ? ft?.ToString() ?? "Roboto" : "Roboto", ["font_code"] = font.TryGetValue("code", out var fc) ? fc?.ToString() ?? "Roboto Mono" : "Roboto Mono", + ["updated_display"] = page.Updated?.ToString("MMMM d, yyyy"), + ["created_display"] = page.Created?.ToString("MMMM d, yyyy"), + ["show_source_meta"] = !page.IsGenerated && page.Updated.HasValue, }; return engine.Render(template, model); diff --git a/src/Netdocs.Plugins/GitRevisionDatePlugin.cs b/src/Netdocs.Plugins/GitRevisionDatePlugin.cs new file mode 100644 index 0000000..9f6a35f --- /dev/null +++ b/src/Netdocs.Plugins/GitRevisionDatePlugin.cs @@ -0,0 +1,99 @@ +using LibGit2Sharp; +using Microsoft.Extensions.Logging; +using Netdocs.Abstractions; + +namespace Netdocs.Plugins; + +/// +/// Sets per-page created/last-updated dates from git history (with a filesystem +/// fallback). Uses a single reverse-history walk to collect first/last commit dates +/// for every tracked path, so cost is one traversal regardless of page count. +/// +public sealed class GitRevisionDatePlugin : IPlugin, IBuildHook +{ + private bool _enableCreationDate; + private ILogger _log = null!; + + public string Name => "git-revision-date-localized"; + + public void Configure(IPluginContext ctx) + { + _log = ctx.Logger; + if (ctx.PluginOptions.TryGetValue("enable_creation_date", out var c) && c is bool b) + _enableCreationDate = b; + } + + public Task OnBuildStartAsync(SiteContext site, CancellationToken ct) + { + // Skip the git-history walk during `serve` to keep incremental rebuilds fast. + if (site.Options.IsServe) + { + ApplyFilesystem(site); + return Task.CompletedTask; + } + + var repoPath = Repository.Discover(site.Config.ProjectRoot); + if (repoPath is null) + { + _log.LogDebug("No git repository found; using filesystem timestamps"); + ApplyFilesystem(site); + return Task.CompletedTask; + } + + try + { + var (created, updated) = CollectDates(repoPath, ct); + var workDir = new Repository(repoPath).Info.WorkingDirectory; + var matched = 0; + foreach (var page in site.Pages) + { + if (page.IsGenerated || !File.Exists(page.SourcePath)) continue; + var rel = Path.GetRelativePath(workDir, page.SourcePath).Replace('\\', '/'); + if (updated.TryGetValue(rel, out var u)) { page.Updated = u; matched++; } + else page.Updated ??= File.GetLastWriteTimeUtc(page.SourcePath); + if (_enableCreationDate && created.TryGetValue(rel, out var cr)) page.Created ??= cr; + } + _log.LogDebug("git-revision-date: resolved dates for {Matched} pages", matched); + } + catch (Exception ex) + { + _log.LogWarning(ex, "git-revision-date failed; using filesystem timestamps"); + ApplyFilesystem(site); + } + return Task.CompletedTask; + } + + private static (Dictionary Created, Dictionary Updated) + CollectDates(string repoPath, CancellationToken ct) + { + var created = new Dictionary(StringComparer.OrdinalIgnoreCase); + var updated = new Dictionary(StringComparer.OrdinalIgnoreCase); + + using var repo = new Repository(repoPath); + var filter = new CommitFilter { SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time }; + foreach (var commit in repo.Commits.QueryBy(filter)) + { + ct.ThrowIfCancellationRequested(); + var when = commit.Author.When; + var parent = commit.Parents.FirstOrDefault(); + using var changes = repo.Diff.Compare(parent?.Tree, commit.Tree); + foreach (var change in changes) + { + var path = change.Path; + updated.TryAdd(path, when); // first seen (newest commit) = last modified + created[path] = when; // last write wins (oldest commit) = created + } + } + return (created, updated); + } + + private static void ApplyFilesystem(SiteContext site) + { + foreach (var page in site.Pages) + { + if (page.IsGenerated || !File.Exists(page.SourcePath)) continue; + page.Updated ??= File.GetLastWriteTimeUtc(page.SourcePath); + page.Created ??= File.GetCreationTimeUtc(page.SourcePath); + } + } +} diff --git a/src/Netdocs.Plugins/Netdocs.Plugins.csproj b/src/Netdocs.Plugins/Netdocs.Plugins.csproj index aaf262e..22daa94 100644 --- a/src/Netdocs.Plugins/Netdocs.Plugins.csproj +++ b/src/Netdocs.Plugins/Netdocs.Plugins.csproj @@ -6,5 +6,6 @@ + diff --git a/src/Netdocs.Plugins/StubPlugins.cs b/src/Netdocs.Plugins/StubPlugins.cs index d4d8c56..11278bc 100644 --- a/src/Netdocs.Plugins/StubPlugins.cs +++ b/src/Netdocs.Plugins/StubPlugins.cs @@ -10,24 +10,6 @@ public void Configure(IPluginContext ctx) { } public bool ShouldInclude(Page page, SiteContext site) => true; } -/// Sets created/updated dates. Currently uses filesystem timestamps; git integration is a TODO. -public sealed class GitRevisionDatePlugin : IPlugin, IBuildHook -{ - public string Name => "git-revision-date-localized"; - public void Configure(IPluginContext ctx) { } - - public Task OnBuildStartAsync(SiteContext site, CancellationToken ct) - { - foreach (var page in site.Pages) - { - if (page.IsGenerated || !File.Exists(page.SourcePath)) continue; - page.Updated ??= File.GetLastWriteTimeUtc(page.SourcePath); - page.Created ??= File.GetCreationTimeUtc(page.SourcePath); - } - return Task.CompletedTask; - } -} - /// Injects the GLightbox assets used for image lightboxes. public sealed class GlightboxPlugin : IPlugin { diff --git a/src/Netdocs.Plugins/TagsPlugin.cs b/src/Netdocs.Plugins/TagsPlugin.cs index 37ef20a..107c6db 100644 --- a/src/Netdocs.Plugins/TagsPlugin.cs +++ b/src/Netdocs.Plugins/TagsPlugin.cs @@ -54,19 +54,30 @@ public Task OnBuildStartAsync(SiteContext site, CancellationToken ct) return Task.CompletedTask; } - /// Replaces the <!-- material/tags --> marker with a rendered tag index. + /// Replaces the <!-- material/tags --> marker with a rendered, hierarchical tag index. private static void RenderTagIndex(SiteContext site, SortedDictionary> index) { const string marker = ""; - var markdown = new System.Text.StringBuilder(); + + // Build a tree from '/'-separated tag paths so parents nest their children. + var root = new TagNode(); foreach (var (tag, pages) in index) { - markdown.Append("## ").AppendLine(tag).AppendLine(); - foreach (var page in pages.DistinctBy(p => p.Url).OrderBy(GetDisplayTitle, StringComparer.OrdinalIgnoreCase)) - markdown.Append("- [").Append(GetDisplayTitle(page)).Append("](/").Append(page.Url).AppendLine(")"); - markdown.AppendLine(); + var node = root; + var path = ""; + foreach (var segment in tag.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + path = path.Length == 0 ? segment : path + "/" + segment; + if (!node.Children.TryGetValue(segment, out var child)) + node.Children[segment] = child = new TagNode { FullPath = path }; + node = child; + } + node.Pages = pages; } + var markdown = new System.Text.StringBuilder(); + RenderNode(root, 0, markdown); + foreach (var page in site.Pages) { if (page.RawMarkdown.Contains(marker, StringComparison.Ordinal)) @@ -74,6 +85,29 @@ private static void RenderTagIndex(SiteContext site, SortedDictionary 0 }) + { + foreach (var page in child.Pages.DistinctBy(p => p.Url).OrderBy(GetDisplayTitle, StringComparer.OrdinalIgnoreCase)) + markdown.Append("- [").Append(GetDisplayTitle(page)).Append("](/").Append(page.Url).AppendLine(")"); + markdown.AppendLine(); + } + RenderNode(child, depth + 1, markdown); + } + } + + private sealed class TagNode + { + public string FullPath = ""; + public SortedDictionary Children { get; } = new(StringComparer.OrdinalIgnoreCase); + public List? Pages { get; set; } + } + /// Title known before render: front matter, else first H1 in markdown, else filename. private static string GetDisplayTitle(Page page) { diff --git a/src/Netdocs.Theme.Material/templates/main.html b/src/Netdocs.Theme.Material/templates/main.html index 29675d6..ef6481f 100644 --- a/src/Netdocs.Theme.Material/templates/main.html +++ b/src/Netdocs.Theme.Material/templates/main.html @@ -107,6 +107,16 @@
{{ content }} + {{~ if show_source_meta ~}} +
+
+ + + Last updated: {{ updated_display }} + {{~ if created_display ~}} · Created: {{ created_display }}{{~ end ~}} + +
+ {{~ end ~}}
From 8be99839ebb8c60dc64bd4a7a4b47ad318c4429c Mon Sep 17 00:00:00 2001 From: Netdocs Date: Mon, 20 Jul 2026 17:56:48 -0500 Subject: [PATCH 006/142] Add 'exclude' config option to omit include-only files from discovery --- src/Netdocs.Abstractions/SiteConfig.cs | 3 +++ src/Netdocs.Core/Configuration/JsonConfigLoader.cs | 1 + src/Netdocs.Core/Content/ContentDiscovery.cs | 2 +- src/Netdocs.Core/Content/IgnoreRules.cs | 11 +++++++++++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Netdocs.Abstractions/SiteConfig.cs b/src/Netdocs.Abstractions/SiteConfig.cs index a7e39ce..db05805 100644 --- a/src/Netdocs.Abstractions/SiteConfig.cs +++ b/src/Netdocs.Abstractions/SiteConfig.cs @@ -29,6 +29,9 @@ public sealed class SiteConfig public IReadOnlyList ExtraCss { get; set; } = []; public IReadOnlyList ExtraJavaScript { get; set; } = []; + /// Glob patterns (docs-relative) to exclude from page discovery. + public IReadOnlyList Exclude { get; set; } = []; + /// extra: block, kept as a raw tree for templates. public IReadOnlyDictionary Extra { get; set; } = new Dictionary(); diff --git a/src/Netdocs.Core/Configuration/JsonConfigLoader.cs b/src/Netdocs.Core/Configuration/JsonConfigLoader.cs index ca8484d..2e77b25 100644 --- a/src/Netdocs.Core/Configuration/JsonConfigLoader.cs +++ b/src/Netdocs.Core/Configuration/JsonConfigLoader.cs @@ -37,6 +37,7 @@ public static SiteConfig Load(string appSettingsPath) Plugins = ParsePlugins(root.Get("plugins")), ExtraCss = StringList(root.Get("extraCss")), ExtraJavaScript = StringList(root.Get("extraJavaScript")), + Exclude = StringList(root.Get("exclude")), Extra = root.Get("extra").AsMap(), }; } diff --git a/src/Netdocs.Core/Content/ContentDiscovery.cs b/src/Netdocs.Core/Content/ContentDiscovery.cs index 20973fb..8305229 100644 --- a/src/Netdocs.Core/Content/ContentDiscovery.cs +++ b/src/Netdocs.Core/Content/ContentDiscovery.cs @@ -14,7 +14,7 @@ public IReadOnlyList Discover() if (!Directory.Exists(docsDir)) throw new DirectoryNotFoundException($"docs_dir not found: {docsDir}"); - var ignore = IgnoreRules.Load(config.ProjectRoot, docsDir); + var ignore = IgnoreRules.Load(config.ProjectRoot, docsDir, config.Exclude); var pages = new List(); foreach (var file in Directory.EnumerateFiles(docsDir, "*", SearchOption.AllDirectories)) diff --git a/src/Netdocs.Core/Content/IgnoreRules.cs b/src/Netdocs.Core/Content/IgnoreRules.cs index 3bf5cbb..643369c 100644 --- a/src/Netdocs.Core/Content/IgnoreRules.cs +++ b/src/Netdocs.Core/Content/IgnoreRules.cs @@ -28,6 +28,17 @@ public static IgnoreRules Load(string projectRoot, string docsDir) return new IgnoreRules(patterns); } + public static IgnoreRules Load(string projectRoot, string docsDir, IEnumerable extraPatterns) + { + var rules = Load(projectRoot, docsDir); + foreach (var pattern in extraPatterns) + { + var trimmed = pattern.Trim(); + if (trimmed.Length > 0) rules._patterns.Add(GlobToRegex(trimmed)); + } + return rules; + } + public bool IsIgnored(string relativePath) { foreach (var pattern in _patterns) From 27c1275d5b521798712a778ea1389283b4b77038 Mon Sep 17 00:00:00 2001 From: Netdocs Date: Mon, 20 Jul 2026 18:00:11 -0500 Subject: [PATCH 007/142] Blog post metadata header (date/read-time/categories) + folder-derived categories --- src/Netdocs.Plugins/BlogPlugin.cs | 36 ++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/Netdocs.Plugins/BlogPlugin.cs b/src/Netdocs.Plugins/BlogPlugin.cs index 842bbdf..7823051 100644 --- a/src/Netdocs.Plugins/BlogPlugin.cs +++ b/src/Netdocs.Plugins/BlogPlugin.cs @@ -47,7 +47,9 @@ public Task OnBuildStartAsync(SiteContext site, CancellationToken ct) page.OutputPath = Path.Combine(site.Config.AbsoluteSiteDir, ContentDiscovery.OutputFileFor(url)); page.Created = date; - _posts.Add(new BlogPost(page, date, ReadList(page, "categories"), ReadExcerpt(page))); + var categories = GetCategories(page); + _posts.Add(new BlogPost(page, date, categories, ReadExcerpt(page))); + page.RawMarkdown = PostMetaHtml(date, categories, page.RawMarkdown) + page.RawMarkdown; } _posts.Sort((a, b) => b.Date.CompareTo(a.Date)); @@ -158,6 +160,38 @@ private static List ReadList(Page page, string key) return []; } + /// Categories from front matter, else derived from the post's category folder. + private List GetCategories(Page page) + { + var fromMeta = ReadList(page, "categories"); + if (fromMeta.Count > 0) return fromMeta; + + var rel = page.RelativePath.Replace('\\', '/'); + var prefix = _blogDir + "posts/"; + if (rel.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + var segments = rel[prefix.Length..].Split('/'); + var folders = segments[..^1].Where(s => !int.TryParse(s, out _)).ToList(); + if (folders.Count > 0) return [folders[0]]; + } + return fromMeta; + } + + /// Small metadata header (date · reading time · categories) prepended to a post. + private static string PostMetaHtml(DateTimeOffset date, IReadOnlyList categories, string markdown) + { + var words = markdown.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; + var minutes = Math.Max(1, (int)Math.Round(words / 238.0)); + var cats = categories.Count > 0 ? " · in " + string.Join(", ", categories) : ""; + return $""" + + + + """; + } + private static string ReadExcerpt(Page page) { var md = page.RawMarkdown; From 07d7b4d42bc5041c4e9c029d2bce55d0eb181e18 Mon Sep 17 00:00:00 2001 From: Netdocs Date: Mon, 20 Jul 2026 18:01:50 -0500 Subject: [PATCH 008/142] Add prev/next page navigation in footer (navigation.footer) --- src/Netdocs.Core/BuildEngine.cs | 1 + src/Netdocs.Core/Content/NavigationBuilder.cs | 16 ++++++++++++ src/Netdocs.Core/PageRenderer.cs | 13 ++++++++++ .../templates/partials/footer.html | 26 +++++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/src/Netdocs.Core/BuildEngine.cs b/src/Netdocs.Core/BuildEngine.cs index 03ee7ff..2190a25 100644 --- a/src/Netdocs.Core/BuildEngine.cs +++ b/src/Netdocs.Core/BuildEngine.cs @@ -90,6 +90,7 @@ public async Task BuildAsync(CancellationToken ct = default) // 7. Resolve navigation. site.Navigation = NavigationBuilder.Build(config, site.Pages); + site.State["nav_pages"] = NavigationBuilder.Flatten(site.Navigation); _log.LogDebug("Resolved navigation ({Count} top-level nodes)", site.Navigation.Count); // 8. Template render (parallel) + emit. diff --git a/src/Netdocs.Core/Content/NavigationBuilder.cs b/src/Netdocs.Core/Content/NavigationBuilder.cs index 61852a2..a1328cf 100644 --- a/src/Netdocs.Core/Content/NavigationBuilder.cs +++ b/src/Netdocs.Core/Content/NavigationBuilder.cs @@ -40,4 +40,20 @@ private static IReadOnlyList AutoNav(IReadOnlyList pages) .Select(p => new NavNode { Title = p.Title, Page = p }) .ToList(); } + + /// Depth-first flatten of the nav tree to the linear order of real pages. + public static List Flatten(IReadOnlyList nodes) + { + var result = new List(); + void Walk(IReadOnlyList ns) + { + foreach (var node in ns) + { + if (node.Page is not null) result.Add(node.Page); + if (node.Children.Count > 0) Walk(node.Children); + } + } + Walk(nodes); + return result; + } } diff --git a/src/Netdocs.Core/PageRenderer.cs b/src/Netdocs.Core/PageRenderer.cs index 858d540..a4c0718 100644 --- a/src/Netdocs.Core/PageRenderer.cs +++ b/src/Netdocs.Core/PageRenderer.cs @@ -16,6 +16,17 @@ public static string Render(TemplateEngine engine, SiteContext site, Page page, var palette = site.Config.Theme.Palette.Count > 0 ? site.Config.Theme.Palette[0] : null; var font = site.Config.Theme.Font; + Page? prev = null, next = null; + if (site.State.GetValueOrDefault("nav_pages") is List navPages) + { + var i = navPages.IndexOf(page); + if (i >= 0) + { + if (i > 0) prev = navPages[i - 1]; + if (i < navPages.Count - 1) next = navPages[i + 1]; + } + } + var model = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["config"] = site.Config, @@ -38,6 +49,8 @@ public static string Render(TemplateEngine engine, SiteContext site, Page page, ["updated_display"] = page.Updated?.ToString("MMMM d, yyyy"), ["created_display"] = page.Created?.ToString("MMMM d, yyyy"), ["show_source_meta"] = !page.IsGenerated && page.Updated.HasValue, + ["prev_page"] = prev, + ["next_page"] = next, }; return engine.Render(template, model); diff --git a/src/Netdocs.Theme.Material/templates/partials/footer.html b/src/Netdocs.Theme.Material/templates/partials/footer.html index 61517dc..bdc6aab 100644 --- a/src/Netdocs.Theme.Material/templates/partials/footer.html +++ b/src/Netdocs.Theme.Material/templates/partials/footer.html @@ -1,4 +1,30 @@