diff --git a/.build.Nuke/Build.Compile.cs b/.build.Nuke/Build.Compile.cs
index 718ee69e81ff1733a4f46273dfac00b108de023f..b07b145ffac266baf1465a99f7096b28f3bf439e 100644
--- a/.build.Nuke/Build.Compile.cs
+++ b/.build.Nuke/Build.Compile.cs
@@ -2,6 +2,7 @@ using Nuke.Common;
using Nuke.Common.IO;
using Nuke.Common.Tools.DotNet;
using Nuke.Common.Utilities.Collections;
+using Serilog;
using static Nuke.Common.Tools.DotNet.DotNetTasks;
namespace SuCoS;
@@ -12,16 +13,17 @@ namespace SuCoS;
///
sealed partial class Build : NukeBuild
{
- [Parameter("output-directory (default: ./output)")]
- readonly string outputDirectory = RootDirectory / "output";
-
Target Clean => _ => _
.Executes(() =>
{
- sourceDirectory.GlobDirectories("**/bin", "**/obj").ForEach(
+ sourceDirectory.GlobDirectories("**/bin", "**/obj", "**/output").ForEach(
+ (path) => path.DeleteDirectory()
+ );
+ testDirectory.GlobDirectories("**/bin", "**/obj", "**/output").ForEach(
(path) => path.DeleteDirectory()
);
- PublishDirectory.CreateOrCleanDirectory();
+ PublishDirectory.DeleteDirectory();
+ coverageDirectory.DeleteDirectory();
});
Target Restore => _ => _
@@ -36,11 +38,12 @@ sealed partial class Build : NukeBuild
.DependsOn(Restore)
.Executes(() =>
{
+ Log.Debug("Configuration {Configuration}", configurationSet);
+ Log.Debug("configuration {configuration}", configuration);
DotNetBuild(s => s
.SetNoLogo(true)
.SetProjectFile(solution)
- .SetConfiguration(configuration)
- .SetOutputDirectory(outputDirectory)
+ .SetConfiguration(configurationSet)
.EnableNoRestore()
);
});
diff --git a/.build.Nuke/Build.Publish.cs b/.build.Nuke/Build.Publish.cs
index 13bf9e700ac6e247b113773c973999aa155330fc..e3836c44f26e83945bc0a4ab144dde88eea41db8 100644
--- a/.build.Nuke/Build.Publish.cs
+++ b/.build.Nuke/Build.Publish.cs
@@ -34,7 +34,7 @@ sealed partial class Build : NukeBuild
DotNetPublish(s => s
.SetNoLogo(true)
.SetProject(solution)
- .SetConfiguration(configuration)
+ .SetConfiguration(configurationSet)
.SetOutput(PublishDirectory)
.SetRuntime(runtimeIdentifier)
.SetSelfContained(publishSelfContained)
diff --git a/.build.Nuke/Build.Solution.cs b/.build.Nuke/Build.Solution.cs
index 40877b60743388e0259d49920257c09cce6cf458..2612b15775c93f6be4ad6c8966f7cfca7c664032 100644
--- a/.build.Nuke/Build.Solution.cs
+++ b/.build.Nuke/Build.Solution.cs
@@ -11,7 +11,8 @@ namespace SuCoS;
sealed partial class Build : NukeBuild
{
[Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")]
- readonly Configuration configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release;
+ readonly string configuration;
+ string configurationSet => configuration ?? (IsLocalBuild ? Configuration.Debug : Configuration.Release);
[Solution]
readonly Solution solution;
diff --git a/.build.Nuke/Build.Test.cs b/.build.Nuke/Build.Test.cs
new file mode 100644
index 0000000000000000000000000000000000000000..dccde7febff7da5d35f5086291a9ad801ce14589
--- /dev/null
+++ b/.build.Nuke/Build.Test.cs
@@ -0,0 +1,61 @@
+using Nuke.Common;
+using Nuke.Common.Tools.DotNet;
+using Nuke.Common.Tools.OpenCover;
+using static Nuke.Common.Tools.ReportGenerator.ReportGeneratorTasks;
+using Nuke.Common.Tools.ReportGenerator;
+using Nuke.Common.IO;
+using Nuke.Common.Tools.Coverlet;
+using static Nuke.Common.Tools.Coverlet.CoverletTasks;
+using static Nuke.Common.IO.FileSystemTasks;
+
+namespace SuCoS;
+
+///
+/// This is the main build file for the project.
+/// This partial is responsible for the build process.
+///
+sealed partial class Build : NukeBuild
+{
+ AbsolutePath TestDLL => testDirectory / "bin" / "Debug" / "net7.0";
+ AbsolutePath testDirectory => RootDirectory / "test";
+ AbsolutePath TestSiteDirectory => RootDirectory / "test" / ".TestSites";
+ AbsolutePath TestOutputDirectory => TestDLL / ".TestSites";
+ AbsolutePath coverageDirectory => RootDirectory / "coverage-results";
+ AbsolutePath ReportDirectory => coverageDirectory / "report";
+ AbsolutePath CoverageResultDirectory => coverageDirectory / "coverage";
+ AbsolutePath CoverageResultFile => CoverageResultDirectory / "coverage.cobertura.xml";
+
+ Target PrepareTestFiles => _ => _
+ .After(Clean)
+ .Executes(() =>
+ {
+ TestOutputDirectory.CreateOrCleanDirectory();
+ CopyDirectoryRecursively(TestSiteDirectory, TestOutputDirectory, DirectoryExistsPolicy.Merge);
+ });
+
+ Target Test => _ => _
+ .DependsOn(Compile, PrepareTestFiles)
+ .Executes(() =>
+ {
+ CoverageResultDirectory.CreateDirectory();
+ Coverlet(s => s
+ .SetTarget("dotnet")
+ .SetTargetArgs("test --no-build --no-restore")
+ .SetAssembly(TestDLL / "test.dll")
+ // .SetThreshold(75)
+ .SetOutput(CoverageResultFile)
+ .SetFormat(CoverletOutputFormat.opencover));
+ });
+
+ Target TestReport => _ => _
+ .DependsOn(Test)
+ .AssuredAfterFailure()
+ .Executes(() =>
+ {
+ ReportDirectory.CreateDirectory();
+ ReportGenerator(s => s
+ .SetTargetDirectory(ReportDirectory)
+ .SetReportTypes(new ReportTypes[] { ReportTypes.Html })
+ .SetReports(CoverageResultFile));
+ });
+}
diff --git a/.build.Nuke/_build_nuke.csproj b/.build.Nuke/_build_nuke.csproj
index f5b93a471dfec58c075cd00a491de0734bb919e5..0b25bea5df851645ffcf896d53ea0220d8084cc0 100644
--- a/.build.Nuke/_build_nuke.csproj
+++ b/.build.Nuke/_build_nuke.csproj
@@ -11,11 +11,14 @@
+
+
+
diff --git a/.gitignore b/.gitignore
index e6195539daf506ebba61125d87e3d44524a30793..0de7bb9b2d0bc3d6b4c16b3316312fc91266b19d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,4 @@ artifacts/
output/
project.fragment.lock.json
project.lock.json
+**/coverage-results/
\ No newline at end of file
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index d03173709aa1fde943abb712d483e13c22b2f1c2..d8c2684e845cb5f38dc104d508315c4e45f984f1 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,7 +1,7 @@
image: mcr.microsoft.com/dotnet/sdk:7.0
stages:
- - build
+ - build-test
- check-and-create-release
- publish
@@ -27,22 +27,22 @@ services:
- docker:dind
# build the project on every commit
-build:
+test:
<<: *dotnet_nuke_template
- stage: build
+ stage: build-test
except:
- tags
- schedules
script:
- |
- ./build.sh Compile \
- --output-directory "./output"
+ ./build.sh TestReport \
+ --configuration "Debug"
artifacts:
paths:
- - output/*
+ - coverage-results/report/*
-# # check if there is new commits, if so, create a tag and a release
-# # this will trigger the publish stage "publish"
+# check if there is new commits, if so, create a tag and a release
+# this will trigger the publish stage "publish"
check-and-create-release:
<<: *dotnet_nuke_template
stage: check-and-create-release
diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json
index b0c262ba472312399d0afbc8075ea2b325358c4c..7d2aca0300f1d9c02a06af381d66d4616e31a833 100644
--- a/.nuke/build.schema.json
+++ b/.nuke/build.schema.json
@@ -8,11 +8,7 @@
"properties": {
"configuration": {
"type": "string",
- "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)",
- "enum": [
- "k__BackingField",
- "k__BackingField"
- ]
+ "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)"
},
"containerDefaultRID": {
"type": "string",
@@ -63,10 +59,6 @@
"type": "boolean",
"description": "Disables displaying the NUKE logo"
},
- "outputDirectory": {
- "type": "string",
- "description": "output-directory (default: ./output)"
- },
"packageName": {
"type": "string",
"description": "package-name (default: SuCoS)"
@@ -123,9 +115,12 @@
"CreatePackage",
"GitLabCreateRelease",
"GitLabCreateTag",
+ "PrepareTestFiles",
"Publish",
"Restore",
- "ShowCurrentVersion"
+ "ShowCurrentVersion",
+ "Test",
+ "TestReport"
]
}
},
@@ -146,9 +141,12 @@
"CreatePackage",
"GitLabCreateRelease",
"GitLabCreateTag",
+ "PrepareTestFiles",
"Publish",
"Restore",
- "ShowCurrentVersion"
+ "ShowCurrentVersion",
+ "Test",
+ "TestReport"
]
}
},
diff --git a/SuCoS.sln b/SuCoS.sln
index 7100ee9b00825278fe23bfe3b519819bb8d8895f..45adce0ff8022c7992503796edb48a0b5afc6265 100644
--- a/SuCoS.sln
+++ b/SuCoS.sln
@@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SuCoS", "source\SuCoS.cspro
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build_nuke", ".build.Nuke\_build_nuke.csproj", "{26DB04F6-DA88-43D7-8F4B-535D4D68C24E}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "test", "test\test.csproj", "{F3D789FD-6AC5-4A45-B9AC-079035F5909C}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -24,6 +26,10 @@ Global
{2395B57B-24D7-47AB-B400-7AC6C1FCAFA1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2395B57B-24D7-47AB-B400-7AC6C1FCAFA1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2395B57B-24D7-47AB-B400-7AC6C1FCAFA1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F3D789FD-6AC5-4A45-B9AC-079035F5909C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F3D789FD-6AC5-4A45-B9AC-079035F5909C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F3D789FD-6AC5-4A45-B9AC-079035F5909C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F3D789FD-6AC5-4A45-B9AC-079035F5909C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{2395B57B-24D7-47AB-B400-7AC6C1FCAFA1} = {1A575294-ABB9-4BCF-8FF7-9981A26A55F9}
diff --git a/source/BaseGeneratorCommand.cs b/source/BaseGeneratorCommand.cs
index e3713a821c44616f9b600443f8376941ebc53e0b..0231d33becf20a3f09beec654233326f52974757 100644
--- a/source/BaseGeneratorCommand.cs
+++ b/source/BaseGeneratorCommand.cs
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using Fluid;
using Fluid.Values;
using Serilog;
+using SuCoS.Helper;
using SuCoS.Models;
using SuCoS.Parser;
@@ -32,22 +33,34 @@ public abstract class BaseGeneratorCommand
///
/// The stopwatch reporter.
///
- protected readonly StopwatchReporter stopwatch = new();
+ protected readonly StopwatchReporter stopwatch;
+
+ ///
+ /// The logger (Serilog).
+ ///
+ protected ILogger logger;
///
/// Initializes a new instance of the class.
///
/// The generate options.
- protected BaseGeneratorCommand(IGenerateOptions options)
+ /// The logger instance. Injectable for testing
+ protected BaseGeneratorCommand(IGenerateOptions options, ILogger logger)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
+ if (logger is null)
+ {
+ throw new ArgumentNullException(nameof(logger));
+ }
+ this.logger = logger;
+ stopwatch = new(logger);
- Log.Information("Source path: {source}", propertyValue: options.Source);
+ logger.Information("Source path: {source}", propertyValue: options.Source);
- site = SiteHelper.Init(configFile, options, frontmatterParser, WhereParamsFilter, stopwatch);
+ site = SiteHelper.Init(configFile, options, frontmatterParser, WhereParamsFilter, logger, stopwatch);
}
///
diff --git a/source/BuildCommand.cs b/source/BuildCommand.cs
index 0930c98b5256310f6792be07105c9addc6854a27..5cad285191fe60b2068cde81ba5d3abc338b1859 100644
--- a/source/BuildCommand.cs
+++ b/source/BuildCommand.cs
@@ -11,22 +11,20 @@ namespace SuCoS;
///
public class BuildCommand : BaseGeneratorCommand
{
- private readonly BuildOptions options;
-
///
/// Entry point of the build command. It will be called by the main program
/// in case the build command is invoked (which is by default).
///
- ///
- public BuildCommand(BuildOptions options) : base(options: options)
+ /// Command line options
+ /// The logger instance. Injectable for testing
+ public BuildCommand(BuildOptions options, ILogger logger) : base(options, logger)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
- this.options = options;
- Log.Information("Output path: {output}", options.Output);
+ logger.Information("Output path: {output}", options.Output);
// Generate the site pages
CreateOutputFiles();
@@ -64,7 +62,7 @@ public class BuildCommand : BaseGeneratorCommand
File.WriteAllText(outputAbsolutePath, result);
// Log
- Log.Debug("Page created: {Permalink}", frontmatter.Permalink);
+ logger.Debug("Page created: {Permalink}", frontmatter.Permalink);
// Use interlocked to safely increment the counter in a multi-threaded environment
_ = Interlocked.Increment(ref pagesCreated);
diff --git a/source/Helpers/FileUtils.cs b/source/Helpers/FileUtils.cs
index 584cc4854662d0a250f00f34d5260c2ed1966bd6..b17034e7a80ca0f68b504a0d1008b30385a1a463 100644
--- a/source/Helpers/FileUtils.cs
+++ b/source/Helpers/FileUtils.cs
@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.IO;
using SuCoS.Models;
-namespace SuCoS;
+namespace SuCoS.Helper;
///
/// Helper methods for scanning files.
diff --git a/source/Helpers/SiteHelper.cs b/source/Helpers/SiteHelper.cs
index cbdcc97de7d4da4c7eb1677a7cfdebc6ac01049d..7c860f39037a28b64c8e4433d7b7a48df179a6d9 100644
--- a/source/Helpers/SiteHelper.cs
+++ b/source/Helpers/SiteHelper.cs
@@ -2,10 +2,11 @@ using System;
using System.IO;
using Fluid;
using Microsoft.Extensions.FileProviders;
+using Serilog;
using SuCoS.Models;
using SuCoS.Parser;
-namespace SuCoS;
+namespace SuCoS.Helper;
///
/// Helper methods for scanning files.
@@ -20,8 +21,9 @@ public static class SiteHelper
/// The frontmatter parser.
/// The site settings file.
/// The method to be used in the whereParams.
+ /// The logger instance. Injectable for testing
/// The site settings.
- public static Site ParseSettings(string configFile, IGenerateOptions options, IFrontmatterParser frontmatterParser, FilterDelegate whereParamsFilter)
+ public static Site ParseSettings(string configFile, IGenerateOptions options, IFrontmatterParser frontmatterParser, FilterDelegate whereParamsFilter, ILogger logger)
{
if (options is null)
{
@@ -44,15 +46,13 @@ public static class SiteHelper
var fileContent = File.ReadAllText(filePath);
var site = frontmatterParser.ParseSiteSettings(fileContent);
+ site.Logger = logger;
site.options = options;
site.SourceDirectoryPath = options.Source;
site.OutputPath = options.Output;
// Liquid template options, needed to theme the content
// but also parse URLs
- site.TemplateOptions.MemberAccessStrategy.Register();
- site.TemplateOptions.MemberAccessStrategy.Register();
- site.TemplateOptions.MemberAccessStrategy.Register();
site.TemplateOptions.Filters.AddFilter("whereParams", whereParamsFilter);
if (site is null)
@@ -72,7 +72,7 @@ public static class SiteHelper
/// Creates the pages dictionary.
///
///
- public static Site Init(string configFile, IGenerateOptions options, IFrontmatterParser frontmatterParser, FilterDelegate whereParamsFilter, StopwatchReporter stopwatch)
+ public static Site Init(string configFile, IGenerateOptions options, IFrontmatterParser frontmatterParser, FilterDelegate whereParamsFilter, ILogger logger, StopwatchReporter stopwatch)
{
if (stopwatch is null)
{
@@ -82,7 +82,7 @@ public static class SiteHelper
Site site;
try
{
- site = SiteHelper.ParseSettings(configFile, options, frontmatterParser, whereParamsFilter);
+ site = SiteHelper.ParseSettings(configFile, options, frontmatterParser, whereParamsFilter, logger);
}
catch
{
diff --git a/source/Helpers/StopwatchReporter.cs b/source/Helpers/StopwatchReporter.cs
index 39ebd9149ea6b9aab7892d780f00c55b22e390df..5a691f7b6e1be37172b8ebe87be26904e2cf3019 100644
--- a/source/Helpers/StopwatchReporter.cs
+++ b/source/Helpers/StopwatchReporter.cs
@@ -5,7 +5,7 @@ using System.Globalization;
using System.Linq;
using Serilog;
-namespace SuCoS;
+namespace SuCoS.Helper;
///
/// This class is used to report the time taken to execute
@@ -14,14 +14,16 @@ namespace SuCoS;
///
public class StopwatchReporter
{
+ private readonly ILogger logger;
private readonly Dictionary stopwatches;
private readonly Dictionary itemCounts;
///
/// Constructor
///
- public StopwatchReporter()
+ public StopwatchReporter(ILogger logger)
{
+ this.logger = logger;
stopwatches = new Dictionary();
itemCounts = new Dictionary();
}
@@ -102,6 +104,6 @@ Total {totalDurationAllSteps} ms
═════════════════════════════════════════════";
// Log the report
- Log.Information(report, siteTitle);
+ logger.Information(report, siteTitle);
}
}
\ No newline at end of file
diff --git a/source/Helpers/Urlizer.cs b/source/Helpers/Urlizer.cs
index d371adb2ba42c1bbc5c0d3966338372278375149..715e0c08ab661a6ecc390d4bcd232ce54e4e9fe8 100644
--- a/source/Helpers/Urlizer.cs
+++ b/source/Helpers/Urlizer.cs
@@ -1,16 +1,19 @@
using System;
+using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
-namespace SuCoS;
+namespace SuCoS.Helper;
///
/// Helper class to convert a string to a URL-friendly string.
///
public static partial class Urlizer
{
- [GeneratedRegex(@"[^a-z0-9.]")]
- private static partial Regex UrlizeRegex();
+ [GeneratedRegex(@"[^a-zA-Z0-9]+")]
+ private static partial Regex UrlizeRegexAlpha();
+ [GeneratedRegex(@"[^a-zA-Z0-9.]+")]
+ private static partial Regex UrlizeRegexAlphaDot();
///
/// Converts a string to a URL-friendly string.
@@ -22,25 +25,19 @@ public static partial class Urlizer
///
public static string Urlize(string title, UrlizerOptions? options = null)
{
- if (title == null)
- {
- throw new ArgumentNullException(nameof(title));
- }
+ title ??= "";
options ??= new UrlizerOptions(); // Use default options if not provided
- var cleanedTitle = title;
+ var cleanedTitle = !options.LowerCase ? title : title.ToLower(CultureInfo.CurrentCulture);
- // Apply culture-specific case conversion if enabled
- if (options.LowerCase)
- {
- cleanedTitle = cleanedTitle.ToLower(CultureInfo.CurrentCulture);
- }
+ var replacementChar = options.ReplacementChar ?? '\0';
+ var replacementCharString = options.ReplacementChar.ToString() ?? "";
// Remove non-alphanumeric characters and replace spaces with the replacement character
- cleanedTitle = UrlizeRegex()
- .Replace(cleanedTitle, options.ReplacementChar.ToString())
- .Trim(options.ReplacementChar);
+ cleanedTitle = (options.ReplaceDot ? UrlizeRegexAlpha() : UrlizeRegexAlphaDot())
+ .Replace(cleanedTitle, replacementCharString)
+ .Trim(replacementChar);
return cleanedTitle;
}
@@ -53,12 +50,17 @@ public static partial class Urlizer
///
public static string UrlizePath(string path, UrlizerOptions? options = null)
{
- var items = (path ?? string.Empty).Split("/");
+ var pathString = (path ?? string.Empty);
+ var items = pathString.Split("/");
+ var result = new List();
for (var i = 0; i < items.Length; i++)
{
- items[i] = Urlize(items[i], options);
+ if (!string.IsNullOrEmpty(items[i]))
+ {
+ result.Add(Urlize(items[i], options));
+ }
}
- return string.Join("/", items);
+ return (pathString.StartsWith('/') ? '/' : string.Empty) + string.Join('/', result);
}
}
@@ -76,5 +78,11 @@ public class UrlizerOptions
///
/// The character that will be used to replace spaces and other invalid characters.
///
- public char ReplacementChar { get; set; } = '-';
+ public char? ReplacementChar { get; set; } = '-';
+
+ ///
+ /// Replace dots with the replacement character.
+ /// Note that it will break file paths and domain names.
+ ///
+ public bool ReplaceDot { get; set; }
}
diff --git a/source/Models/Frontmatter.cs b/source/Models/Frontmatter.cs
index 79129b5e4c6fe550c5c37faf18d4e1362f982e5b..bf5a41aa6c695a5e45ad310c74c8db3fa41af1d6 100644
--- a/source/Models/Frontmatter.cs
+++ b/source/Models/Frontmatter.cs
@@ -5,10 +5,9 @@ using System.IO;
using System.Linq;
using Fluid;
using Markdig;
-using Serilog;
-using SuCoS.Models;
+using SuCoS.Helper;
-namespace SuCoS;
+namespace SuCoS.Models;
///
/// The meta data about each content Markdown file.
@@ -138,10 +137,10 @@ public class Frontmatter : IBaseContent, IParams
{
get
{
- if (contentCacheTime is null || Site.IgnoreCacheBefore >= contentCacheTime)
+ if (contentCacheTime is null || Site.IgnoreCacheBefore > contentCacheTime)
{
contentCache = CreateContent();
- contentCacheTime = DateTime.UtcNow;
+ contentCacheTime = clock.UtcNow;
}
return contentCache!;
}
@@ -170,7 +169,10 @@ public class Frontmatter : IBaseContent, IParams
pagesCached ??= new();
foreach (var permalink in PagesReferences)
{
- pagesCached.Add(Site.PagesDict[permalink]);
+ if (permalink is not null)
+ {
+ pagesCached.Add(Site.PagesDict[permalink]);
+ }
}
}
return pagesCached;
@@ -205,12 +207,12 @@ public class Frontmatter : IBaseContent, IParams
///
/// Check if the page is expired
///
- public bool IsDateExpired => ExpiryDate is not null && ExpiryDate >= DateTime.Now;
+ public bool IsDateExpired => ExpiryDate is not null && ExpiryDate <= clock.Now;
///
/// Check if the page is publishable
///
- public bool IsDatePublishable => GetPublishDate is null || GetPublishDate <= DateTime.Now;
+ public bool IsDatePublishable => GetPublishDate is null || GetPublishDate <= clock.Now;
///
/// The markdown content.
@@ -233,6 +235,8 @@ public class Frontmatter : IBaseContent, IParams
private DateTime? GetPublishDate => PublishDate ?? Date;
+ private readonly ISystemClock clock;
+
///
/// Required.
///
@@ -240,9 +244,11 @@ public class Frontmatter : IBaseContent, IParams
string title,
string sourcePath,
Site site,
+ ISystemClock clock,
string? sourceFileNameWithoutExtension = null,
string? sourcePathDirectory = null)
{
+ this.clock = clock;
Title = title;
Site = site;
SourcePath = sourcePath;
@@ -268,35 +274,35 @@ public class Frontmatter : IBaseContent, IParams
public string CreatePermalink(string? URLforce = null)
{
var isIndex = SourceFileNameWithoutExtension == "index";
- string outputRelativePath;
+ var permaLink = string.Empty;
URLforce ??= URL
?? (isIndex ? "{{ page.SourcePathDirectory }}" : "{{ page.SourcePathDirectory }}/{{ page.Title }}");
- outputRelativePath = URLforce;
-
- if (Site.FluidParser.TryParse(URLforce, out var template, out var error))
+ try
{
- var context = new TemplateContext(Site.TemplateOptions)
- .SetValue("page", this);
- try
+ if (Site.FluidParser.TryParse(URLforce, out var template, out var error))
{
- outputRelativePath = template.Render(context);
+ var context = new TemplateContext(Site.TemplateOptions)
+ .SetValue("page", this);
+ permaLink = template.Render(context);
}
- catch (Exception ex)
+ else
{
- Log.Error(ex, "Error converting URL: {Error}", error);
+ throw new FormatException(error);
}
}
+ catch (Exception ex)
+ {
+ Site.Logger?.Error(ex, "Error converting URL: {URLforce}", URLforce);
+ }
- outputRelativePath = Urlizer.UrlizePath(outputRelativePath);
-
- if (!Path.IsPathRooted(outputRelativePath) && !outputRelativePath.StartsWith("/"))
+ if (!Path.IsPathRooted(permaLink) && !permaLink.StartsWith('/'))
{
- outputRelativePath = "/" + outputRelativePath;
+ permaLink = '/' + permaLink;
}
- return outputRelativePath;
+ return Urlizer.UrlizePath(permaLink);
}
///
@@ -326,7 +332,7 @@ public class Frontmatter : IBaseContent, IParams
}
else
{
- Log.Error("Error parsing theme template: {Error}", error);
+ Site.Logger?.Error("Error parsing theme template: {Error}", error);
return string.Empty;
}
@@ -341,9 +347,9 @@ public class Frontmatter : IBaseContent, IParams
{
var fileContents = FileUtils.GetTemplate(Site.SourceThemePath, this, Site);
// Theme content
- if (string.IsNullOrEmpty(value: fileContents))
+ if (string.IsNullOrEmpty(fileContents))
{
- return Content;
+ return ContentPreRendered;
}
else if (Site.FluidParser.TryParse(fileContents, out var template, out var error))
{
@@ -356,13 +362,13 @@ public class Frontmatter : IBaseContent, IParams
}
catch (Exception ex)
{
- Log.Error(ex, "Error rendering theme template: {Error}", error);
+ Site.Logger?.Error(ex, "Error rendering theme template: {Error}", error);
return string.Empty;
}
}
else
{
- Log.Error("Error parsing theme template: {Error}", error);
+ Site.Logger?.Error("Error parsing theme template: {Error}", error);
return string.Empty;
}
}
diff --git a/source/Models/ISystemClock.cs b/source/Models/ISystemClock.cs
new file mode 100644
index 0000000000000000000000000000000000000000..bd389003fef397fc66e2864d71bf020b0d8ff043
--- /dev/null
+++ b/source/Models/ISystemClock.cs
@@ -0,0 +1,35 @@
+using System;
+
+namespace SuCoS.Models;
+
+///
+/// Represents an interface for accessing the system clock.
+///
+public interface ISystemClock
+{
+ ///
+ /// Gets the current local date and time.
+ ///
+ DateTime Now { get; }
+
+ ///
+ /// Gets the current Coordinated Universal Time (UTC).
+ ///
+ DateTime UtcNow { get; }
+}
+
+///
+/// Represents a concrete implementation of the ISystemClock interface using the system clock.
+///
+public class SystemClock : ISystemClock
+{
+ ///
+ /// Gets the current local date and time.
+ ///
+ public DateTime Now => DateTime.Now;
+
+ ///
+ /// Gets the current Coordinated Universal Time (UTC).
+ ///
+ public DateTime UtcNow => DateTime.UtcNow;
+}
diff --git a/source/Models/Site.cs b/source/Models/Site.cs
index 69470c216c4f6c904b759b7d51fe8f537431e418..cdcf70f058ad9e53ee41d71104c3537f4d23703a 100644
--- a/source/Models/Site.cs
+++ b/source/Models/Site.cs
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Fluid;
using Markdig;
using Serilog;
+using SuCoS.Helper;
using SuCoS.Parser;
namespace SuCoS.Models;
@@ -140,6 +141,11 @@ public class Site : IParams
///
public readonly TemplateOptions TemplateOptions = new();
+ ///
+ /// The logger instance.
+ ///
+ public ILogger? Logger;
+
///
/// The time that the older cache should be ignored.
///
@@ -169,6 +175,8 @@ public class Site : IParams
private List? regularPagesCache;
+ private readonly ISystemClock clock;
+
///
/// Markdig 20+ built-in extensions
///
@@ -177,6 +185,26 @@ public class Site : IParams
.UseAdvancedExtensions()
.Build();
+ ///
+ /// Constructor
+ ///
+ public Site() : this(new SystemClock())
+ {
+ }
+
+ ///
+ /// Constructor
+ ///
+ public Site(ISystemClock clock)
+ {
+ // Liquid template options, needed to theme the content
+ // but also parse URLs
+ TemplateOptions.MemberAccessStrategy.Register();
+ TemplateOptions.MemberAccessStrategy.Register();
+
+ this.clock = clock;
+ }
+
///
/// Scans all markdown files in the source directory.
///
@@ -227,6 +255,7 @@ public class Site : IParams
if (!automaticContentCache.TryGetValue(id, out frontmatter))
{
frontmatter = new(
+ clock: new SystemClock(),
site: this,
title: baseContent.Title,
sourcePath: string.Empty,
@@ -294,7 +323,7 @@ public class Site : IParams
}
catch (Exception ex)
{
- Log.Error(ex, "Error parsing file {file}", file.filePath);
+ Logger?.Error(ex, "Error parsing file {file}", file.filePath);
}
// Use interlocked to safely increment the counter in a multi-threaded environment
@@ -322,6 +351,7 @@ public class Site : IParams
Frontmatter frontmatter = new(
title: Title,
site: this,
+ clock: clock,
sourcePath: Path.Combine(relativePath, "index.md"),
sourceFileNameWithoutExtension: "index",
sourcePathDirectory: "/"
@@ -340,8 +370,13 @@ public class Site : IParams
///
///
///
- private void PostProcessFrontMatter(Frontmatter frontmatter, bool overwrite = false)
+ public void PostProcessFrontMatter(Frontmatter frontmatter, bool overwrite = false)
{
+ if (frontmatter is null)
+ {
+ throw new ArgumentNullException(nameof(frontmatter));
+ }
+
frontmatter.Permalink = frontmatter.CreatePermalink();
lock (syncLockPostProcess)
{
diff --git a/source/Parser/YAMLParser.cs b/source/Parser/YAMLParser.cs
index 48ffd89cbaed7cf41ee3df7852f74e3450b13738..7ec42e10280989bae43016ed5dd3f13d45d4cc14 100644
--- a/source/Parser/YAMLParser.cs
+++ b/source/Parser/YAMLParser.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
+using SuCoS.Helper;
using SuCoS.Models;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
@@ -40,6 +41,7 @@ public partial class YAMLParser : IFrontmatterParser
{
throw new ArgumentNullException(nameof(site));
}
+ var dateTime = new SystemClock();
Frontmatter? frontmatter = null;
var match = YAMLRegex().Match(fileContent);
@@ -80,6 +82,7 @@ public partial class YAMLParser : IFrontmatterParser
frontmatter = new(
title: titleValue?.ToString() ?? sourceFileNameWithoutExtension,
site: site,
+ clock: dateTime,
sourcePath: filePath,
sourceFileNameWithoutExtension: sourceFileNameWithoutExtension,
sourcePathDirectory: null
@@ -121,6 +124,10 @@ public partial class YAMLParser : IFrontmatterParser
_ = site.CreateAutomaticFrontmatter(contentTemplate, frontmatter);
}
}
+ else
+ {
+ throw new YamlDotNet.Core.YamlException("Frontmatter yaml parsing failed");
+ }
}
if (frontmatter is not null)
{
@@ -131,7 +138,12 @@ public partial class YAMLParser : IFrontmatterParser
return null;
}
- private static string GetSection(string filePath)
+ ///
+ /// Get the section name from a file path
+ ///
+ ///
+ ///
+ public static string GetSection(string filePath)
{
// Split the path into individual folders
var folders = filePath?.Split(Path.DirectorySeparatorChar);
@@ -154,8 +166,17 @@ public partial class YAMLParser : IFrontmatterParser
/// Site or Frontmatter object, that implements IParams
/// The type (Site or Frontmatter)
/// YAML content
- void ParseParams(IParams settings, Type type, string content)
+ public void ParseParams(IParams settings, Type type, string content)
{
+ if (settings is null)
+ {
+ throw new ArgumentNullException(nameof(settings));
+ }
+ if (type is null)
+ {
+ throw new ArgumentNullException(nameof(type));
+ }
+
var yamlObject = yamlDeserializer.Deserialize(new StringReader(content));
if (yamlObject is Dictionary
public class Program
{
- private static int Main(string[] args)
+ private ILogger logger;
+
+ ///
+ /// Constructor
+ ///
+ public Program(ILogger logger)
+ {
+ this.logger = logger;
+ }
+
+ ///
+ /// Entry point of the program
+ ///
+ ///
+ ///
+ public static int Main(string[] args)
{
- // use Serilog to log the program's output
- Log.Logger = new LoggerConfiguration()
+ ILogger logger = new LoggerConfiguration()
.WriteTo.Console(formatProvider: System.Globalization.CultureInfo.CurrentCulture)
.CreateLogger();
+ var program = new Program(logger);
+ return program.Run(args);
+ }
+
+ private int Run(string[] args)
+ {
// Print the logo of the program.
OutputLogo();
@@ -26,7 +46,7 @@ public class Program
var assemblyName = assembly?.GetName();
var appName = assemblyName?.Name;
var appVersion = assemblyName?.Version;
- Log.Information("{name} v{version}", appName, appVersion);
+ logger.Information("{name} v{version}", appName, appVersion);
// Shared options between the commands
var sourceOption = new Option(new[] { "--source", "-s" }, () => ".", "Source directory path");
@@ -51,11 +71,11 @@ public class Program
Output = output,
Future = future
};
- Log.Logger = new LoggerConfiguration()
+ logger = new LoggerConfiguration()
.MinimumLevel.Is(verbose ? LogEventLevel.Debug : LogEventLevel.Information)
.WriteTo.Console(formatProvider: System.Globalization.CultureInfo.CurrentCulture)
.CreateLogger();
- _ = new BuildCommand(buildOptions);
+ _ = new BuildCommand(buildOptions, logger);
},
sourceOption, buildOutputOption, futureOption, verboseOption);
@@ -73,12 +93,12 @@ public class Program
Source = source,
Future = future
};
- Log.Logger = new LoggerConfiguration()
+ logger = new LoggerConfiguration()
.MinimumLevel.Is(verbose ? LogEventLevel.Debug : LogEventLevel.Information)
.WriteTo.Console(formatProvider: System.Globalization.CultureInfo.CurrentCulture)
.CreateLogger();
- var serveCommand = new ServeCommand(serverOptions);
+ var serveCommand = new ServeCommand(serverOptions, logger);
await serveCommand.RunServer();
await Task.Delay(-1); // Wait forever.
},
@@ -93,9 +113,9 @@ public class Program
return rootCommand.Invoke(args);
}
- private static void OutputLogo()
+ private void OutputLogo()
{
- Log.Information(@"
+ logger.Information(@"
____ ____ ____
/\ _`\ /\ _`\ /\ _`\
\ \,\L\_\ __ __\ \ \/\_\ ___\ \,\L\_\
diff --git a/source/ServeCommand.cs b/source/ServeCommand.cs
index 368c18eb23daf0a134130f7cff591198e12704f6..f3043e72cef3db0400dcf586ed0d82cf9a96a903 100644
--- a/source/ServeCommand.cs
+++ b/source/ServeCommand.cs
@@ -9,6 +9,8 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
+using SuCoS.Helper;
+using SuCoS.Models;
namespace SuCoS;
@@ -89,7 +91,7 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable
/// A Task representing the asynchronous operation.
public async Task StartServer(string baseURL, int port)
{
- Log.Information("Starting server...");
+ logger.Information("Starting server...");
// Generate the build report
stopwatch.LogReport(site.Title);
@@ -108,7 +110,7 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable
.Build();
await host.StartAsync();
- Log.Information("You site is live: {baseURL}:{port}", baseURL, port);
+ logger.Information("You site is live: {baseURL}:{port}", baseURL, port);
}
@@ -125,7 +127,8 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable
/// Constructor for the ServeCommand class.
///
/// ServeOptions object specifying the serve options.
- public ServeCommand(ServeOptions options) : base(options)
+ /// The logger instance. Injectable for testing
+ public ServeCommand(ServeOptions options, ILogger logger) : base(options, logger)
{
this.options = options ?? throw new ArgumentNullException(nameof(options));
var baseURL = "http://localhost";
@@ -145,7 +148,7 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable
{
var SourceAbsolutePath = Path.GetFullPath(SourcePath);
- Log.Information("Watching for file changes in {SourceAbsolutePath}", SourceAbsolutePath);
+ logger.Information("Watching for file changes in {SourceAbsolutePath}", SourceAbsolutePath);
var fileWatcher = new FileSystemWatcher
{
@@ -173,7 +176,7 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable
try
{
- site = SiteHelper.Init(configFile, options, frontmatterParser, WhereParamsFilter, stopwatch);
+ site = SiteHelper.Init(configFile, options, frontmatterParser, WhereParamsFilter, logger, stopwatch);
// Stop the server
if (host != null)
@@ -203,7 +206,7 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable
var fileAbsolutePath = Path.Combine(options.Source, "static", requestPath.TrimStart('/'));
- Log.Debug("Request received for {RequestPath}", requestPath);
+ logger.Debug("Request received for {RequestPath}", requestPath);
// Return the server startup timestamp as the response
if (requestPath == "/ping")
@@ -277,7 +280,7 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable
///
/// The content to inject the reload script into.
/// The content with the reload script injected.
- private static string InjectReloadScript(string content)
+ private string InjectReloadScript(string content)
{
// Read the content of the JavaScript file
string scriptContent;
@@ -291,8 +294,8 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable
}
catch (Exception ex)
{
- Log.Error(ex, "Could not read the JavaScript file.");
- throw ex;
+ logger.Error(ex, "Could not read the JavaScript file.");
+ throw;
}
// Inject the JavaScript content
@@ -318,7 +321,7 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable
{
if (!restartInProgress)
{
- Log.Information("File change detected: {FullPath}", e.FullPath);
+ logger.Information("File change detected: {FullPath}", e.FullPath);
restartInProgress = true;
await RestartServer();
diff --git a/test/.TestSites/01/content/test01.md b/test/.TestSites/01/content/test01.md
new file mode 100644
index 0000000000000000000000000000000000000000..2d3c4158bf543cd81271fdb19c46fd6442f43d12
--- /dev/null
+++ b/test/.TestSites/01/content/test01.md
@@ -0,0 +1,5 @@
+---
+Title: Test Content 1
+---
+
+Test Content 1
diff --git a/test/.TestSites/01/content/test02.md b/test/.TestSites/01/content/test02.md
new file mode 100644
index 0000000000000000000000000000000000000000..7dfb40d58b564b8177d0abbb482489925a4b0f52
--- /dev/null
+++ b/test/.TestSites/01/content/test02.md
@@ -0,0 +1,5 @@
+---
+Title: Test Content 2
+---
+
+Test Content 2
diff --git a/test/BaseGeneratorCommandTests.cs b/test/BaseGeneratorCommandTests.cs
new file mode 100644
index 0000000000000000000000000000000000000000..e65e4192a254f51752a6ac4ef1b0445992d824fe
--- /dev/null
+++ b/test/BaseGeneratorCommandTests.cs
@@ -0,0 +1,63 @@
+using System.Reflection;
+using Fluid;
+using Fluid.Values;
+using Serilog;
+using Xunit;
+
+namespace SuCoS.Tests;
+
+public class BaseGeneratorCommandTests
+{
+ private static readonly IGenerateOptions testOptions = new BuildOptions
+ {
+ Source = "test_source"
+ };
+
+ private static readonly ILogger testLogger = new LoggerConfiguration().CreateLogger();
+
+ private class BaseGeneratorCommandStub : BaseGeneratorCommand
+ {
+ public BaseGeneratorCommandStub(IGenerateOptions options, ILogger logger)
+ : base(options, logger) { }
+ }
+
+ [Fact]
+ public void Constructor_ShouldThrowArgumentNullException_WhenOptionsIsNull()
+ {
+ Assert.Throws(() => new BaseGeneratorCommandStub(null!, testLogger));
+ }
+
+ [Fact]
+ public void Constructor_ShouldThrowArgumentNullException_WhenLoggerIsNull()
+ {
+ Assert.Throws(() => new BaseGeneratorCommandStub(testOptions, null!));
+ }
+
+ [Fact]
+ public async Task WhereParamsFilter_ShouldThrowArgumentNullException_WhenInputIsNull()
+ {
+ await Assert.ThrowsAsync(
+ () => BaseGeneratorCommand.WhereParamsFilter(null!, new FilterArguments(), new TemplateContext()).AsTask());
+ }
+
+ [Fact]
+ public async Task WhereParamsFilter_ShouldThrowArgumentNullException_WhenArgumentsIsNull()
+ {
+ await Assert.ThrowsAsync(
+ () => BaseGeneratorCommand.WhereParamsFilter(new ArrayValue(new FluidValue[0]), null!, new TemplateContext()).AsTask());
+ }
+
+ [Fact]
+ public void CheckValueInDictionary_ShouldWorkCorrectly()
+ {
+ var type = typeof(BaseGeneratorCommand);
+ var method = type.GetMethod("CheckValueInDictionary", BindingFlags.NonPublic | BindingFlags.Static);
+ var parameters = new object[] { new[] { "key" }, new Dictionary { { "key", "value" } }, "value" };
+
+ Assert.NotNull(method);
+ var result = method.Invoke(null, parameters);
+
+ Assert.NotNull(result);
+ Assert.True((bool)result!);
+ }
+}
diff --git a/test/Helpers/UrlizerTests.cs b/test/Helpers/UrlizerTests.cs
new file mode 100644
index 0000000000000000000000000000000000000000..09a1c9d31cd873c3f7242eb661c32b6eb30b2b71
--- /dev/null
+++ b/test/Helpers/UrlizerTests.cs
@@ -0,0 +1,91 @@
+using Xunit;
+using SuCoS.Helper;
+
+namespace SuCoSTests;
+
+public class UrlizerTests
+{
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void Urlize_NullOrEmptyText_ThrowsArgumentNullException(string text)
+ {
+ var result = Urlizer.Urlize(text);
+ Assert.Equal("", result);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void UrlizePath_NullPath_ReturnsEmptyString(string path)
+ {
+ var result = Urlizer.UrlizePath(path);
+
+ Assert.Equal("", result);
+ }
+
+ [Theory]
+ [InlineData("Hello, World!", '-', true, false, "hello-world")]
+ [InlineData("Hello, World!", '_', true, false, "hello_world")]
+ [InlineData("Hello, World!", '-', false, false, "Hello-World")]
+ [InlineData("Hello.World", '-', true, false, "hello.world")]
+ [InlineData("Hello.World", '-', true, true, "hello-world")]
+ public void Urlize_ValidText_ReturnsExpectedResult(string text, char? replacementChar, bool lowerCase, bool replaceDot, string expectedResult)
+ {
+ var options = new UrlizerOptions { ReplacementChar = replacementChar, LowerCase = lowerCase, ReplaceDot = replaceDot };
+ var result = Urlizer.Urlize(text, options);
+
+ Assert.Equal(expectedResult, result);
+ }
+
+ [Theory]
+ [InlineData("Documents/My Report.docx", '-', true, false, "documents/my-report.docx")]
+ [InlineData("Documents/My Report.docx", '_', true, false, "documents/my_report.docx")]
+ [InlineData("Documents/My Report.docx", '-', false, false, "Documents/My-Report.docx")]
+ [InlineData("Documents/My Report.docx", '-', true, true, "documents/my-report-docx")]
+ [InlineData("C:/Documents/My Report.docx", '_', true, true, "c/documents/my_report_docx")]
+ [InlineData("Documents/My Report.docx", null, true, false, "documents/myreport.docx")]
+ public void UrlizePath_ValidPath_ReturnsExpectedResult(string path, char? replacementChar, bool lowerCase, bool replaceDot, string expectedResult)
+ {
+ var options = new UrlizerOptions { ReplacementChar = replacementChar, LowerCase = lowerCase, ReplaceDot = replaceDot };
+ var result = Urlizer.UrlizePath(path, options);
+
+ Assert.Equal(expectedResult, result);
+ }
+
+ [Fact]
+ public void Urlize_WithoutOptions_ReturnsExpectedResult()
+ {
+ var text = "Hello, World!";
+ var result = Urlizer.Urlize(text);
+
+ Assert.Equal("hello-world", result);
+ }
+
+ [Fact]
+ public void UrlizePath_WithoutOptions_ReturnsExpectedResult()
+ {
+ var path = "Documents/My Report.docx";
+ var result = Urlizer.UrlizePath(path);
+
+ Assert.Equal("documents/my-report.docx", result);
+ }
+
+ [Fact]
+ public void Urlize_SpecialCharsInText_ReturnsOnlyHyphens()
+ {
+ var text = "!@#$%^&*()";
+ var result = Urlizer.Urlize(text);
+
+ Assert.Equal("", result);
+ }
+
+ [Fact]
+ public void UrlizePath_SpecialCharsInPath_ReturnsOnlyHyphens()
+ {
+ var path = "/!@#$%^&*()/";
+ var result = Urlizer.UrlizePath(path);
+
+ Assert.Equal("/", result);
+ }
+}
diff --git a/test/Models/BasicContentTests.cs b/test/Models/BasicContentTests.cs
new file mode 100644
index 0000000000000000000000000000000000000000..a492b600f88623525e564b45c4f4b4a588dd686b
--- /dev/null
+++ b/test/Models/BasicContentTests.cs
@@ -0,0 +1,38 @@
+namespace SuCoS.Models.Tests;
+
+using Xunit;
+using SuCoS.Models;
+
+public class BasicContentTests
+{
+ [Theory]
+ [InlineData("Title1", "Section1", "Type1", "URL1", Kind.single)]
+ [InlineData("Title2", "Section2", "Type2", "URL2", Kind.list)]
+ [InlineData("Title3", "Section3", "Type3", "URL3", Kind.index)]
+ public void Constructor_Sets_Properties_Correctly(string title, string section, string type, string url, Kind kind)
+ {
+ // Act
+ var basicContent = new BasicContent(title, section, type, url, kind);
+
+ // Assert
+ Assert.Equal(title, basicContent.Title);
+ Assert.Equal(section, basicContent.Section);
+ Assert.Equal(type, basicContent.Type);
+ Assert.Equal(url, basicContent.URL);
+ Assert.Equal(kind, basicContent.Kind);
+ }
+
+ [Fact]
+ public void Constructor_Sets_Kind_To_List_If_Not_Provided()
+ {
+ // Arrange
+ string title = "Title1", section = "Section1", type = "Type1", url = "URL1";
+ var kind = Kind.list;
+
+ // Act
+ var basicContent = new BasicContent(title, section, type, url);
+
+ // Assert
+ Assert.Equal(kind, basicContent.Kind);
+ }
+}
diff --git a/test/Models/FrontmatterTests.cs b/test/Models/FrontmatterTests.cs
new file mode 100644
index 0000000000000000000000000000000000000000..89f6f8d30232acd8cf4e1dbf36466f360608ba85
--- /dev/null
+++ b/test/Models/FrontmatterTests.cs
@@ -0,0 +1,172 @@
+using System.Globalization;
+using Moq;
+using SuCoS.Models;
+using Xunit;
+
+namespace SuCoS.Tests;
+
+public class FrontmatterTests
+{
+ private readonly ISystemClock clock;
+ private readonly Mock systemClockMock;
+ private readonly string title = "Test Title";
+ private readonly string sourcePath = "/path/to/file.md";
+ private readonly Site site;
+
+ public FrontmatterTests()
+ {
+ systemClockMock = new Mock();
+ var testDate = DateTime.Parse("2023-04-01", CultureInfo.InvariantCulture);
+ systemClockMock.Setup(c => c.Now).Returns(testDate);
+
+ clock = systemClockMock.Object;
+ site = new(clock);
+ }
+
+ [Theory]
+ [InlineData("Test Title", "/path/to/file.md", "file", "/path/to")]
+ public void ShouldCreateFrontmatterWithCorrectProperties(string title, string sourcePath, string sourceFileNameWithoutExtension, string sourcePathDirectory)
+ {
+ var frontmatter = new Frontmatter(title, sourcePath, site, clock, sourceFileNameWithoutExtension, sourcePathDirectory);
+
+ Assert.Equal(title, frontmatter.Title);
+ Assert.Equal(sourcePath, frontmatter.SourcePath);
+ Assert.Same(site, frontmatter.Site);
+ Assert.Equal(sourceFileNameWithoutExtension, frontmatter.SourceFileNameWithoutExtension);
+ Assert.Equal(sourcePathDirectory, frontmatter.SourcePathDirectory);
+ }
+
+ [Fact]
+ public void ShouldHaveDefaultValuesForOptionalProperties()
+ {
+ // Arrange
+ var frontmatter = new Frontmatter("Test Title", "/path/to/file.md", site, clock);
+
+ // Assert
+ Assert.Equal(string.Empty, frontmatter.Section);
+ Assert.Equal(Kind.single, frontmatter.Kind);
+ Assert.Equal(string.Empty, frontmatter.Type);
+ Assert.Null(frontmatter.URL);
+ Assert.Empty(frontmatter.Params);
+ Assert.Null(frontmatter.Date);
+ Assert.Null(frontmatter.LastMod);
+ Assert.Null(frontmatter.PublishDate);
+ Assert.Null(frontmatter.ExpiryDate);
+ Assert.Null(frontmatter.Aliases);
+ Assert.Null(frontmatter.Permalink);
+ Assert.Empty(frontmatter.Urls);
+ Assert.Equal(string.Empty, frontmatter.RawContent);
+ Assert.Null(frontmatter.Tags);
+ Assert.Null(frontmatter.PagesReferences);
+ Assert.Empty(frontmatter.RegularPages);
+ Assert.Equal(string.Empty, frontmatter.Language);
+ Assert.False(frontmatter.IsDateExpired);
+ Assert.True(frontmatter.IsDatePublishable);
+ }
+
+ [Fact]
+ public void ShouldReturnValidDateBasedOnExpiryDateAndPublishDate()
+ {
+ var publishDate = new DateTime(2023, 6, 1);
+ var expiryDate = new DateTime(2023, 6, 3);
+
+ systemClockMock.Setup(c => c.Now).Returns(new DateTime(2023, 6, 2));
+
+ var frontmatter = new Frontmatter(title, sourcePath, site, clock)
+ {
+ ExpiryDate = expiryDate,
+ PublishDate = publishDate
+ };
+
+ Assert.True(frontmatter.IsValidDate(null));
+ }
+
+ [Theory]
+ [InlineData(null, "/path/to/test-title")]
+ [InlineData("{{ page.Title }}/{{ page.SourceFileNameWithoutExtension }}", "/test-title/file")]
+ public void ShouldCreatePermalinkWithDefaultOrCustomURLTemplate(string urlTemplate, string expectedPermalink)
+ {
+ var frontmatter = new Frontmatter(title, sourcePath, site, clock)
+ {
+ URL = urlTemplate
+ };
+
+ var actualPermalink = frontmatter.CreatePermalink();
+
+ Assert.Equal(expectedPermalink, actualPermalink);
+ }
+
+ [Theory]
+ [InlineData(-1, true)]
+ [InlineData(1, false)]
+ public void IsDateExpired_ShouldReturnExpectedResult(int days, bool expected)
+ {
+ systemClockMock.Setup(c => c.Now).Returns(new DateTime(2023, 6, 28));
+
+ var frontmatter = new Frontmatter(title, sourcePath, site, clock)
+ {
+ ExpiryDate = clock.Now.AddDays(days)
+ };
+
+ Assert.Equal(expected, frontmatter.IsDateExpired);
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(true, true)]
+ public void IsValidDate_ShouldReturnExpectedResult(bool futureOption, bool expected)
+ {
+ systemClockMock.Setup(c => c.Now).Returns(new DateTime(2023, 6, 28));
+
+ var frontmatter = new Frontmatter(title, sourcePath, site, clock)
+ {
+ Date = clock.Now.AddDays(1)
+ };
+
+ var options = new Mock();
+ options.Setup(o => o.Future).Returns(futureOption);
+
+ Assert.Equal(expected, frontmatter.IsValidDate(options.Object));
+ }
+
+ [Theory]
+ [InlineData("/test/path", "/test/path/test-title")]
+ [InlineData("/another/path", "/another/path/test-title")]
+ public void CreatePermalink_ShouldReturnCorrectUrl_WhenUrlIsNull(string sourcePathDirectory, string expectedUrl)
+ {
+ var frontmatter = new Frontmatter(title, sourcePath, site, clock)
+ {
+ SourcePathDirectory = sourcePathDirectory
+ };
+
+ Assert.Equal(expectedUrl, frontmatter.CreatePermalink());
+ }
+
+ [Theory]
+ [InlineData(Kind.single, true)]
+ [InlineData(Kind.list, false)]
+ public void RegularPages_ShouldReturnCorrectPages_WhenKindIsSingle(Kind kind, bool isExpectedPage)
+ {
+ var page = new Frontmatter(title, sourcePath, site, clock) { Kind = kind };
+ site.PostProcessFrontMatter(page);
+
+ Assert.Equal(isExpectedPage, site.RegularPages.Contains(page));
+ }
+
+ [Theory]
+ [InlineData(null, null, true)]
+ [InlineData(null, "2024-06-28", false)]
+ [InlineData("2022-06-28", null, true)]
+ [InlineData("2024-06-28", "2022-06-28", false)]
+ [InlineData("2022-06-28", "2024-06-28", true)]
+ public void IsDatePublishable_ShouldReturnCorrectValues(string? publishDate, string? date, bool expectedValue)
+ {
+ var frontmatter = new Frontmatter(title, sourcePath, site, clock)
+ {
+ PublishDate = publishDate is null ? null : DateTime.Parse(publishDate, CultureInfo.InvariantCulture),
+ Date = date is null ? null : DateTime.Parse(date, CultureInfo.InvariantCulture)
+ };
+
+ Assert.Equal(expectedValue, frontmatter.IsDatePublishable);
+ }
+}
diff --git a/test/Models/SiteTests.cs b/test/Models/SiteTests.cs
new file mode 100644
index 0000000000000000000000000000000000000000..9d4ba9c266785bfa5ac735bd179dec23f63faa87
--- /dev/null
+++ b/test/Models/SiteTests.cs
@@ -0,0 +1,62 @@
+using Xunit;
+using Moq;
+using SuCoS.Models;
+using System.Globalization;
+
+namespace SuCoS.Tests;
+
+///
+/// Unit tests for the Site class.
+///
+public class SiteTests
+{
+ private readonly Site site;
+ private readonly Mock systemClockMock;
+ readonly string testSite1Path = ".TestSites/01";
+
+ public SiteTests()
+ {
+ systemClockMock = new Mock();
+ var testDate = DateTime.Parse("2023-04-01", CultureInfo.InvariantCulture);
+ systemClockMock.Setup(c => c.Now).Returns(testDate);
+ site = new Site(systemClockMock.Object);
+ }
+
+ [Theory]
+ [InlineData("test01.md", @"---
+Title: Test Content 1
+---
+
+Test Content 1
+")]
+ [InlineData("test02.md", @"---
+Title: Test Content 2
+---
+
+Test Content 2
+")]
+ public void Test_ScanAllMarkdownFiles(string fileName, string fileContent)
+ {
+ site.SourceDirectoryPath = testSite1Path;
+ site.ScanAllMarkdownFiles();
+
+ Assert.Contains(site.RawPages, rp => rp.filePath == fileName && rp.content == fileContent);
+ }
+
+ [Theory]
+ [InlineData("test1", Kind.index, "base", "Test Content 1")]
+ [InlineData("test2", Kind.single, "content", "Test Content 2")]
+ public void Test_ResetCache(string firstKeyPart, Kind secondKeyPart, string thirdKeyPart, string value)
+ {
+ var key = (firstKeyPart, secondKeyPart, thirdKeyPart);
+ site.baseTemplateCache.Add(key, value);
+ site.contentTemplateCache.Add(key, value);
+ site.PagesDict.Add("test", new Frontmatter("Test Title", "sourcePath", site, systemClockMock.Object));
+
+ site.ResetCache();
+
+ Assert.Empty(site.baseTemplateCache);
+ Assert.Empty(site.contentTemplateCache);
+ Assert.Empty(site.PagesDict);
+ }
+}
diff --git a/test/Parser/YAMLParserTests.cs b/test/Parser/YAMLParserTests.cs
new file mode 100644
index 0000000000000000000000000000000000000000..a2e3551ea45f9f964b712fa2e9cc8334aaee92f1
--- /dev/null
+++ b/test/Parser/YAMLParserTests.cs
@@ -0,0 +1,155 @@
+using Xunit;
+using Moq;
+using SuCoS.Models;
+using SuCoS.Parser;
+using System.Globalization;
+
+namespace SuCoS.Tests;
+
+public class YAMLParserTests
+{
+ private readonly YAMLParser parser;
+ private readonly Mock mockSite;
+
+ public YAMLParserTests()
+ {
+ parser = new YAMLParser();
+ mockSite = new Mock();
+ }
+
+ [Theory]
+ [InlineData(@"---
+Title: Test Title
+---
+", "Test Title")]
+ [InlineData(@"---
+---
+", null)]
+ public void ParseFrontmatter_ShouldParseTitleCorrectly(string fileContent, string expectedTitle)
+ {
+ var filePath = "test.md";
+ var frontmatter = parser.ParseFrontmatter(mockSite.Object, filePath, ref fileContent);
+
+ Assert.Equal(expectedTitle, frontmatter?.Title);
+ }
+
+ [Fact]
+ public void ParseFrontmatter_ShouldThrowException_WhenSiteIsNull()
+ {
+ var fileContent = @"---
+Title: Test Title
+---
+";
+
+ Assert.Throws(() => parser.ParseFrontmatter(null!, "test.md", ref fileContent));
+ }
+
+ [Fact]
+ public void GetSection_ShouldReturnFirstFolderName()
+ {
+ var filePath = Path.Combine("folder1", "folder2", "file.md");
+
+ var section = YAMLParser.GetSection(filePath);
+
+ Assert.Equal("folder1", section);
+ }
+
+ [Theory]
+ [InlineData(@"---
+Date: 2023-01-01
+---
+", "2023-01-01")]
+ [InlineData(@"---
+Date: 2023/01/01
+---
+", "2023-01-01")]
+ public void ParseFrontmatter_ShouldParseDateCorrectly(string fileContent, string expectedDateString)
+ {
+ var filePath = "test.md";
+ var expectedDate = DateTime.Parse(expectedDateString, CultureInfo.InvariantCulture);
+
+ var frontmatter = parser.ParseFrontmatter(mockSite.Object, filePath, ref fileContent);
+
+ Assert.Equal(expectedDate, frontmatter?.Date);
+ }
+
+ [Fact]
+ public void ParseFrontmatter_ShouldParseOtherFieldsCorrectly()
+ {
+ var filePath = "test.md";
+ var fileContent = @"---
+Title: Test Title
+Type: post
+Date: 2023-01-01
+LastMod: 2023-06-01
+PublishDate: 2023-06-01
+ExpiryDate: 2024-06-01
+---
+";
+ var expectedDate = DateTime.Parse("2023-01-01", CultureInfo.InvariantCulture);
+ var expectedLastMod = DateTime.Parse("2023-06-01", CultureInfo.InvariantCulture);
+ var expectedPublishDate = DateTime.Parse("2023-06-01", CultureInfo.InvariantCulture);
+ var expectedExpiryDate = DateTime.Parse("2024-06-01", CultureInfo.InvariantCulture);
+
+ var frontmatter = parser.ParseFrontmatter(mockSite.Object, filePath, ref fileContent);
+
+ Assert.Equal("Test Title", frontmatter?.Title);
+ Assert.Equal("post", frontmatter?.Type);
+ Assert.Equal(expectedDate, frontmatter?.Date);
+ Assert.Equal(expectedLastMod, frontmatter?.LastMod);
+ Assert.Equal(expectedPublishDate, frontmatter?.PublishDate);
+ Assert.Equal(expectedExpiryDate, frontmatter?.ExpiryDate);
+ }
+
+ [Fact]
+ public void ParseFrontmatter_ShouldThrowException_WhenInvalidYAMLSyntax()
+ {
+ var fileContent = @"---
+Title
+---
+";
+ var filePath = "test.md";
+
+ Assert.Throws(() => parser.ParseFrontmatter(mockSite.Object, filePath, ref fileContent));
+ }
+
+ [Fact]
+ public void ParseSiteSettings_ShouldReturnSiteWithCorrectSettings()
+ {
+ var siteContent = @"
+BaseUrl: https://www.example.com/
+Title: My Site
+";
+
+ var siteSettings = parser.ParseSiteSettings(siteContent);
+
+ Assert.Equal("https://www.example.com/", siteSettings.BaseUrl);
+ Assert.Equal("My Site", siteSettings.Title);
+ }
+
+ [Fact]
+ public void ParseParams_ShouldFillParamsWithNonMatchingFields()
+ {
+ var settings = new Frontmatter("Test Title", "/test.md", mockSite.Object, new SystemClock());
+ var content = @"
+Title: Test Title
+customParam: Custom Value
+";
+
+ parser.ParseParams(settings, typeof(Frontmatter), content);
+
+ Assert.True(settings.Params.ContainsKey("customParam"));
+ Assert.Equal("Custom Value", settings.Params["customParam"]);
+ }
+
+ [Fact]
+ public void ParseFrontmatter_ShouldReturnNull_WhenNoFrontmatter()
+ {
+ var fileContent = "There is no frontmatter in this content.";
+ var filePath = "test.md";
+
+ var frontmatter = parser.ParseFrontmatter(mockSite.Object, filePath, ref fileContent);
+
+ Assert.Null(frontmatter);
+ }
+}
diff --git a/test/test.csproj b/test/test.csproj
new file mode 100644
index 0000000000000000000000000000000000000000..520ac34b680d19ba7190debd0094713a0b1f89e6
--- /dev/null
+++ b/test/test.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net7.0
+ enable
+ enable
+
+ false
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+