From 6f41e094a3e87b2421c26672c32dbd70233dc348 Mon Sep 17 00:00:00 2001 From: IanWold Date: Wed, 23 Oct 2024 09:56:25 -0500 Subject: [PATCH] Add solution, project, and program for local running. --- .vscode/tasks.json | 19 +++ Build.csproj | 33 +++++ Build.sln | 22 +++ GlobalSuppressions.cs | 3 + Program.cs | 316 +++++++++++++++++++++++++++++++++++++++++ build-legacy.csx | 320 ++++++++++++++++++++++++++++++++++++++++++ build.csx | 5 +- 7 files changed, 716 insertions(+), 2 deletions(-) create mode 100644 .vscode/tasks.json create mode 100644 Build.csproj create mode 100644 Build.sln create mode 100644 GlobalSuppressions.cs create mode 100644 Program.cs create mode 100644 build-legacy.csx 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 topicsObjectList) + { + foreach (var topicName in topicsObjectList.Cast()) + { + if (topicPosts.TryGetValue(topicName, out var topicPostsList)) + { + topicPosts[topicName] = [ ..topicPostsList, post.Metadata ]; + } + else + { + topicPosts.Add(topicName, [ post.Metadata ]); + } + } + } + } + + foreach (var series in seriesPosts) + { + project.AddOutput(new MetalsharpFile(string.Empty, $".\\Series\\{seriesInfo.Single(s => s.Title == series.Key).Slug}.html", new Dictionary() + { + ["title"] = series.Key, + ["series"] = series.Key, + ["template"] = "archive", + ["removeScrollspy"] = true, + ["posts"] = series.Value.OrderByDescending(p => DateTime.Parse(p["date"].ToString())) + })); + } + + foreach (var topic in topicPosts) + { + project.AddOutput(new MetalsharpFile(string.Empty, $".\\Topics\\{topic.Key.ToLowerInvariant()}.html", new Dictionary() + { + ["title"] = topic.Key, + ["topic"] = topic.Key, + ["template"] = "archive", + ["removeScrollspy"] = true, + ["posts"] = topic.Value.OrderByDescending(p => DateTime.Parse(p["date"].ToString())) + })); + } +}) +.Use(project => // Add table of contents to posts +{ + var posts = project.OutputFiles.Where(f => f.Directory.StartsWith(@".\Posts"));// && f.Metadata.TryGetValue("contents", out object isContentsObject) && isContentsObject is bool isContents && isContents); + + static string getSectionSlug(string sectionName) => + string.Join('-', sectionName.Split(' ')).ToLower(); + + foreach (var post in posts) + { + var postLines = post.Text.Split('\r', '\n').Where(l => !string.IsNullOrWhiteSpace(l)); + var postBuilder = new StringBuilder(); + var sections = new List(); + var isInContainingSection = false; + var isInInnerSection = false; + + void addSection(string sectionName, string sectionSlug, int level) + { + sections.Add(new { name = sectionName, id = sectionSlug, level = level }); + postBuilder.AppendLine($"
"); + } + + string getHeaderWithSectionLink(string sectionSlug, string line) => + $"{line.Substring(0, line.Length - 5)} #{line.Substring(line.Length - 5, 5)}"; + + foreach (var line in postLines) + { + var lineToAdd = line; + var trimmedLine = line.Trim(); + + if (Regex.Match(trimmedLine, @"(?:

)([^<]*)(?:<\/h1>)$") is Match matchContainingHeader && matchContainingHeader.Success) + { + if (isInInnerSection) + { + postBuilder.AppendLine("

"); + isInInnerSection = false; + } + + if (isInContainingSection) + { + postBuilder.AppendLine(""); + } + + var sectionName = matchContainingHeader.Groups[1].Captures[0].Value; + var sectionSlug = getSectionSlug(sectionName); + + addSection(sectionName, sectionSlug, 1); + lineToAdd = getHeaderWithSectionLink(sectionSlug, trimmedLine); + + isInContainingSection = true; + } + else if (Regex.Match(trimmedLine, @"(?:

)([^<]*)(?:<\/h2>)$") is Match matchInnerHeader && matchInnerHeader.Success) + { + if (isInInnerSection) + { + postBuilder.AppendLine(""); + } + + var sectionName = matchInnerHeader.Groups[1].Captures[0].Value; + var sectionSlug = getSectionSlug(sectionName); + + addSection(sectionName, sectionSlug, 2); + lineToAdd = getHeaderWithSectionLink(sectionSlug, trimmedLine); + + isInInnerSection = true; + } + + postBuilder.AppendLine(lineToAdd); + } + + if (isInInnerSection) + { + postBuilder.AppendLine(""); + isInInnerSection = false; + } + + if (isInContainingSection) + { + postBuilder.AppendLine(""); + isInContainingSection = false; + } + + post.Text = postBuilder.ToString(); + post.Metadata.Add("sections", sections); + } +}) +.Use(project => // Add slug and description for "series" files +{ + foreach (var file in project.InputFiles.Concat(project.OutputFiles)) + { + if (file.Metadata.TryGetValue("series", out var seriesNameObj) && seriesNameObj is string seriesName) + { + if (!file.Metadata.ContainsKey("seriesDescription")) + { + file.Metadata.Add("seriesDescription", seriesInfo.Single(s => s.Title == seriesName).Description.Split("\n").Select(d => $"

{d}

")); + } + + if (!file.Metadata.ContainsKey("seriesSlug")) + { + file.Metadata.Add("seriesSlug", seriesInfo.Single(s => s.Title == seriesName).Slug); + } + } + } +}) +.UseLeveller() +.UseLiquidTemplates("Templates") +.AddOutput("Static", @".\") +.Use(project => // Add sitemap +{ + var builder = new StringBuilder(); + builder.AppendLine("https://ian.wold.guru/"); + + foreach (var page in project.OutputFiles.Where(f => f.Extension.Contains("html") && !f.Name.Contains("index"))) + { + builder.AppendLine($"https://ian.wold.guru/{page.FilePath.Replace(".\\", "").Replace("\\", "/")}"); + } + + project.AddOutput(new MetalsharpFile(builder.ToString(), "sitemap.txt")); +}) +.Build(new BuildOptions() +{ + OutputDirectory = "output", + ClearOutputDirectory = true +}); + +record SeriesInfo(string Title, string Slug, string Description); \ No newline at end of file diff --git a/build-legacy.csx b/build-legacy.csx new file mode 100644 index 0000000..81bf763 --- /dev/null +++ b/build-legacy.csx @@ -0,0 +1,320 @@ +#! "net8.0" +#r "nuget: Metalsharp, 0.9.0-rc.5" +#r "nuget: Metalsharp.LiquidTemplates, 0.9.0-rc-3" +#r "nuget: Metalsharp.SimpleBlog, 0.9.0-rc.2" +#r "nuget: System.ServiceModel.Syndication, 8.0.0" + +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; + +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 topicsObjectList) + { + foreach (var topicName in topicsObjectList.Cast()) + { + if (topicPosts.TryGetValue(topicName, out var topicPostsList)) + { + topicPosts[topicName] = [ ..topicPostsList, post.Metadata ]; + } + else + { + topicPosts.Add(topicName, [ post.Metadata ]); + } + } + } + } + + foreach (var series in seriesPosts) + { + project.AddOutput(new MetalsharpFile(string.Empty, $".\\Series\\{seriesInfo.Single(s => s.Title == series.Key).Slug}.html", new Dictionary() + { + ["title"] = series.Key, + ["series"] = series.Key, + ["template"] = "archive", + ["removeScrollspy"] = true, + ["posts"] = series.Value.OrderByDescending(p => DateTime.Parse(p["date"].ToString())) + })); + } + + foreach (var topic in topicPosts) + { + project.AddOutput(new MetalsharpFile(string.Empty, $".\\Topics\\{topic.Key.ToLowerInvariant()}.html", new Dictionary() + { + ["title"] = topic.Key, + ["topic"] = topic.Key, + ["template"] = "archive", + ["removeScrollspy"] = true, + ["posts"] = topic.Value.OrderByDescending(p => DateTime.Parse(p["date"].ToString())) + })); + } +}) +.Use(project => // Add table of contents to posts +{ + var posts = project.OutputFiles.Where(f => f.Directory.StartsWith(@".\Posts"));// && f.Metadata.TryGetValue("contents", out object isContentsObject) && isContentsObject is bool isContents && isContents); + + static string getSectionSlug(string sectionName) => + string.Join('-', sectionName.Split(' ')).ToLower(); + + foreach (var post in posts) + { + var postLines = post.Text.Split('\r', '\n').Where(l => !string.IsNullOrWhiteSpace(l)); + var postBuilder = new StringBuilder(); + var sections = new List(); + var isInContainingSection = false; + var isInInnerSection = false; + + void addSection(string sectionName, string sectionSlug, int level) + { + sections.Add(new { name = sectionName, id = sectionSlug, level = level }); + postBuilder.AppendLine($"
"); + } + + string getHeaderWithSectionLink(string sectionSlug, string line) => + $"{line.Substring(0, line.Length - 5)} #{line.Substring(line.Length - 5, 5)}"; + + foreach (var line in postLines) + { + var lineToAdd = line; + var trimmedLine = line.Trim(); + + if (Regex.Match(trimmedLine, @"(?:

)([^<]*)(?:<\/h1>)$") is Match matchContainingHeader && matchContainingHeader.Success) + { + if (isInInnerSection) + { + postBuilder.AppendLine("

"); + isInInnerSection = false; + } + + if (isInContainingSection) + { + postBuilder.AppendLine(""); + } + + var sectionName = matchContainingHeader.Groups[1].Captures[0].Value; + var sectionSlug = getSectionSlug(sectionName); + + addSection(sectionName, sectionSlug, 1); + lineToAdd = getHeaderWithSectionLink(sectionSlug, trimmedLine); + + isInContainingSection = true; + } + else if (Regex.Match(trimmedLine, @"(?:

)([^<]*)(?:<\/h2>)$") is Match matchInnerHeader && matchInnerHeader.Success) + { + if (isInInnerSection) + { + postBuilder.AppendLine(""); + } + + var sectionName = matchInnerHeader.Groups[1].Captures[0].Value; + var sectionSlug = getSectionSlug(sectionName); + + addSection(sectionName, sectionSlug, 2); + lineToAdd = getHeaderWithSectionLink(sectionSlug, trimmedLine); + + isInInnerSection = true; + } + + postBuilder.AppendLine(lineToAdd); + } + + if (isInInnerSection) + { + postBuilder.AppendLine(""); + isInInnerSection = false; + } + + if (isInContainingSection) + { + postBuilder.AppendLine(""); + isInContainingSection = false; + } + + post.Text = postBuilder.ToString(); + post.Metadata.Add("sections", sections); + } +}) +.Use(project => // Add slug and description for "series" files +{ + foreach (var file in project.InputFiles.Concat(project.OutputFiles)) + { + if (file.Metadata.TryGetValue("series", out var seriesNameObj) && seriesNameObj is string seriesName) + { + if (!file.Metadata.ContainsKey("seriesDescription")) + { + file.Metadata.Add("seriesDescription", seriesInfo.Single(s => s.Title == seriesName).Description.Split("\n").Select(d => $"

{d}

")); + } + + if (!file.Metadata.ContainsKey("seriesSlug")) + { + file.Metadata.Add("seriesSlug", seriesInfo.Single(s => s.Title == seriesName).Slug); + } + } + } +}) +.UseLeveller() +.UseLiquidTemplates("Templates") +.AddOutput("Static", @".\") +.Use(project => // Add sitemap +{ + var builder = new StringBuilder(); + builder.AppendLine("https://ian.wold.guru/"); + + foreach (var page in project.OutputFiles.Where(f => f.Extension.Contains("html") && !f.Name.Contains("index"))) + { + builder.AppendLine($"https://ian.wold.guru/{page.FilePath.Replace(".\\", "").Replace("\\", "/")}"); + } + + project.AddOutput(new MetalsharpFile(builder.ToString(), "sitemap.txt")); +}) +.Build(new BuildOptions() +{ + OutputDirectory = "output", + ClearOutputDirectory = true +}); + +record SeriesInfo(string Title, string Slug, string Description); \ No newline at end of file diff --git a/build.csx b/build.csx index 81bf763..06e3e04 100644 --- a/build.csx +++ b/build.csx @@ -1,9 +1,8 @@ -#! "net8.0" +#! "net8.0" #r "nuget: Metalsharp, 0.9.0-rc.5" #r "nuget: Metalsharp.LiquidTemplates, 0.9.0-rc-3" #r "nuget: Metalsharp.SimpleBlog, 0.9.0-rc.2" #r "nuget: System.ServiceModel.Syndication, 8.0.0" - using Metalsharp; using Metalsharp.LiquidTemplates; using Metalsharp.SimpleBlog; @@ -14,6 +13,8 @@ using System.IO; using System.Xml; using System; using System.Text.Json; +using System.Collections.Generic; +using System.Linq; IEnumerable seriesInfo = [];