diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..ff3cc39
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,19 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "Watch: dotnet watch build",
+ "type": "shell",
+ "command": "dotnet watch build",
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ },
+ "problemMatcher": "$msCompile",
+ "runOptions": {
+ "runOn": "folderOpen"
+ },
+ "isBackground": true
+ }
+ ]
+ }
\ No newline at end of file
diff --git a/Build.csproj b/Build.csproj
new file mode 100644
index 0000000..aebd559
--- /dev/null
+++ b/Build.csproj
@@ -0,0 +1,33 @@
+
+
+ Exe
+ net8.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Build.sln b/Build.sln
new file mode 100644
index 0000000..e8f698b
--- /dev/null
+++ b/Build.sln
@@ -0,0 +1,22 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Build", "Build.csproj", "{A6163207-B097-456C-8721-DEAB980213B9}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {A6163207-B097-456C-8721-DEAB980213B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A6163207-B097-456C-8721-DEAB980213B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A6163207-B097-456C-8721-DEAB980213B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A6163207-B097-456C-8721-DEAB980213B9}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/GlobalSuppressions.cs b/GlobalSuppressions.cs
new file mode 100644
index 0000000..7192230
--- /dev/null
+++ b/GlobalSuppressions.cs
@@ -0,0 +1,3 @@
+using System.Diagnostics.CodeAnalysis;
+
+[assembly: SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "Annoying")]
diff --git a/Program.cs b/Program.cs
new file mode 100644
index 0000000..3ccb616
--- /dev/null
+++ b/Program.cs
@@ -0,0 +1,316 @@
+using Metalsharp;
+using Metalsharp.LiquidTemplates;
+using Metalsharp.SimpleBlog;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.ServiceModel.Syndication;
+using System.IO;
+using System.Xml;
+using System;
+using System.Text.Json;
+using System.Collections.Generic;
+using System.Linq;
+
+IEnumerable seriesInfo = [];
+
+using (var reader = new StreamReader("Config/series.json"))
+{
+ seriesInfo = JsonSerializer.Deserialize>(reader.ReadToEnd());
+}
+
+new MetalsharpProject()
+.AddInput("Site", @".\")
+.UseFrontmatter()
+.RemoveFiles(file =>
+ file.Metadata.TryGetValue("draft", out var isDraftObj)
+ && isDraftObj is bool isDraft
+ && isDraft
+)
+.Use(project => // Add reading time to posts
+{
+ foreach (var file in project.InputFiles.Where(f => f.Directory.StartsWith(@".\Posts")))
+ {
+ file.Metadata.Add("readingTime", (int)Math.Ceiling(file.Text.Split(' ').Length / 200D));
+ }
+})
+.UseMarkdown()
+.UseSimpleBlog(new()
+{
+ PostsDirectory = @".\Posts",
+ PostsOrderQuery = f => DateTime.Parse(f.Metadata["date"] as string ?? DateTime.Now.ToString()),
+ PostMetadata = post =>
+ {
+ var postDate = DateTime.Parse(post.Metadata["date"]?.ToString() ?? "");
+ return new()
+ {
+ ["template"] = "article",
+ ["year"] = postDate.Year.ToString(),
+ ["month"] = postDate.Month.ToString("D2"),
+ ["day"] = postDate.Day.ToString("D2"),
+ ["isodate"] = postDate.ToUniversalTime().ToString("o", System.Globalization.CultureInfo.InvariantCulture),
+ ["slug"] = post.Name
+ };
+ },
+ BlogFilePath = @".\index.html",
+ BlogMetadata = new()
+ {
+ ["title"] = "Software Engineer, Architect, and Team Leader",
+ ["template"] = "archive",
+ ["removeScrollspy"] = true,
+ ["hidePastArticles"] = true,
+ ["fontRequirement"] = "index",
+ ["includeCanonicalIndex"] = true
+ },
+})
+.Use(project => // Generate RSS feed
+{
+ var rssItems = project.OutputFiles.Where(f => f.Directory.StartsWith(@".\Posts")).Select(post => new SyndicationItem(
+ post.Metadata["title"].ToString(),
+ post.Text,
+ new Uri($"https://ian.wold.guru/Posts/{post.Name}.html"),
+ post.Name,
+ DateTime.Parse(post.Metadata["date"]?.ToString() ?? "")
+ ));
+
+ var rssFeedContent = string.Empty;
+ var rssFeed = new SyndicationFeed(
+ "Ian Wold",
+ "Ian Wold's Blog",
+ new Uri("https://ian.wold.guru/feed.xml"),
+ rssItems.Select(i =>
+ {
+ i.PublishDate = i.LastUpdatedTime;
+ return i;
+ })
+ );
+
+ var xmlSettings = new XmlWriterSettings
+ {
+ Indent = true,
+ OmitXmlDeclaration = true,
+ Encoding = Encoding.UTF8
+ };
+ using (var memoryStream = new MemoryStream())
+ using (var xmlWriter = XmlWriter.Create(memoryStream, xmlSettings))
+ {
+ rssFeed.SaveAsRss20(xmlWriter);
+ xmlWriter.Flush();
+ memoryStream.Position = 0;
+
+ using (var reader = new StreamReader(memoryStream))
+ {
+ rssFeedContent = reader.ReadToEnd();
+ }
+ }
+
+ project.AddOutput(new MetalsharpFile(rssFeedContent, "feed.xml"));
+})
+.Use(project => // Add SEO and "series" metadata to posts
+{
+ var seriesPosts = new Dictionary>>();
+ var topicPosts = new Dictionary>>();
+
+ var posts = project.OutputFiles.Where(f => f.Directory.StartsWith(@".\Posts"));
+ foreach (var post in posts)
+ {
+ post.Metadata.Add("structuredData", $$"""
+ {
+ "@context": "https://schema.org",
+ "@type": "Article",
+ "author": [{
+ "@type": "Person",
+ "name": "Ian Wold"
+ }],
+ "datePublished": "{{DateTime.Parse(post.Metadata["date"]?.ToString() ?? "")}}",
+ "image": "https://images.unsplash.com/{{ post.Metadata["hero"]!.ToString()}}",
+ "headline": "{{post.Metadata["title"]!.ToString()}}",
+ "description": "{{post.Metadata["description"]!.ToString()}}",
+ "publisher": {
+ "@type": "Person",
+ "name": "Ian Wold",
+ "logo": {
+ "@type": "ImageObject",
+ "url": "https://ian.wold.guru/images/hero1.svg"
+ }
+ },
+ }
+ """
+ );
+
+ if (post.Metadata.TryGetValue("series", out var seriesObject) && seriesObject is string seriesName)
+ {
+ if (seriesPosts.TryGetValue(seriesName, out var seriesPostsList))
+ {
+ seriesPosts[seriesName] = [ ..seriesPostsList, post.Metadata ];
+ }
+ else
+ {
+ seriesPosts.Add(seriesName, [ post.Metadata ]);
+ }
+ }
+
+ if (post.Metadata.TryGetValue("topics", out var topicsObject) && topicsObject is IEnumerable