diff --git a/.editorconfig b/.editorconfig index ca0a0b95304e3eba862b3e4cfa39df432cff49c0..a02ffc7a6d7a73b06a3f3d3d1ef497e5c7a5e2de 100644 --- a/.editorconfig +++ b/.editorconfig @@ -50,6 +50,8 @@ csharp_new_line_before_members_in_anonymous_types = true:error csharp_new_line_within_query_expression_clauses = true:error csharp_space_after_keywords_in_control_flow_statements = true +trim_trailing_whitespace = true + # Code quality dotnet_diagnostic.CA1062.severity = warning dotnet_diagnostic.CA1303.severity = error diff --git a/source/BaseGeneratorCommand.cs b/source/BaseGeneratorCommand.cs index 31710dde2c85c55179a1284218629ee3fceeb6c6..254146cccf809fed80ebf99e16610febde059a03 100644 --- a/source/BaseGeneratorCommand.cs +++ b/source/BaseGeneratorCommand.cs @@ -26,7 +26,7 @@ public abstract class BaseGeneratorCommand /// /// The front matter parser instance. The default is YAML. /// - protected IMetadataParser frontMatterParser { get; } = new YAMLParser(); + protected IMetadataParser Parser { get; } = new YAMLParser(); /// /// The stopwatch reporter. @@ -52,7 +52,7 @@ public abstract class BaseGeneratorCommand logger.Information("Source path: {source}", propertyValue: options.Source); - site = SiteHelper.Init(configFile, options, frontMatterParser, WhereParamsFilter, logger, stopwatch); + site = SiteHelper.Init(configFile, options, Parser, WhereParamsFilter, logger, stopwatch); } /// diff --git a/source/BuildCommand.cs b/source/BuildCommand.cs index c4bda47b22eb8f4aa8a9f76a3e97a1283954619e..ae0f4d132b1f182b2a1486d63c932ba18e80157e 100644 --- a/source/BuildCommand.cs +++ b/source/BuildCommand.cs @@ -27,7 +27,10 @@ public class BuildCommand : BaseGeneratorCommand CreateOutputFiles(); // Copy theme static folder files into the root of the output folder - CopyFolder(site.SourceThemeStaticPath, options.Output); + if (site.Theme is not null) + { + CopyFolder(site.Theme.StaticFolder, options.Output); + } // Copy static folder files into the root of the output folder CopyFolder(site.SourceStaticPath, options.Output); diff --git a/source/Helpers/SiteHelper.cs b/source/Helpers/SiteHelper.cs index 858d05a3f231007cf2d6b392b4952aa425bc5a15..2dcf23ede622249d91116d03b7ce2b139d94c873 100644 --- a/source/Helpers/SiteHelper.cs +++ b/source/Helpers/SiteHelper.cs @@ -108,7 +108,7 @@ public static class SiteHelper } var fileContent = File.ReadAllText(filePath); - var siteSettings = parser.ParseSiteSettings(fileContent) + var siteSettings = parser.Parse(fileContent) ?? throw new FormatException($"Error reading app config {configFile}"); return siteSettings; } diff --git a/source/Models/CommandLineOptions/NewOptions.cs b/source/Models/CommandLineOptions/NewOptions.cs new file mode 100644 index 0000000000000000000000000000000000000000..71d5be246e37ace329e5a84fae7504aa08f7bfee --- /dev/null +++ b/source/Models/CommandLineOptions/NewOptions.cs @@ -0,0 +1,11 @@ +using CommandLine; + +namespace SuCoS.Models.CommandLineOptions; + +/// +/// Command line options to generate a simple site from scratch. +/// +[Verb("new", false, HelpText = "Generate a simple theme from scratch")] +public class NewOptions +{ +} diff --git a/source/Models/CommandLineOptions/NewSiteOptions.cs b/source/Models/CommandLineOptions/NewSiteOptions.cs index c9fb348eb0343cf235f3327fd7b1e21b9cb2792e..f0dc375fb9232c26ce8d7c6989d0ec5895288704 100644 --- a/source/Models/CommandLineOptions/NewSiteOptions.cs +++ b/source/Models/CommandLineOptions/NewSiteOptions.cs @@ -5,7 +5,7 @@ namespace SuCoS.Models.CommandLineOptions; /// /// Command line options to generate a simple site from scratch. /// -[Verb("newsite", false, HelpText = "Generate a simple site from scratch")] +[Verb("new-site", false, HelpText = "Generate a simple site from scratch")] public class NewSiteOptions { /// diff --git a/source/Models/CommandLineOptions/NewThemeOptions.cs b/source/Models/CommandLineOptions/NewThemeOptions.cs new file mode 100644 index 0000000000000000000000000000000000000000..f512afe27d456369d4a258c45ad5b50e0c1996c0 --- /dev/null +++ b/source/Models/CommandLineOptions/NewThemeOptions.cs @@ -0,0 +1,28 @@ +using CommandLine; + +namespace SuCoS.Models.CommandLineOptions; + +/// +/// Command line options to generate a simple site from scratch. +/// +[Verb("new-theme", false, HelpText = "Generate a simple theme from scratch")] +public class NewThemeOptions +{ + /// + /// The path of the output files. + /// + [Option('o', "output", Required = false, HelpText = "Output directory path")] + public required string Output { get; init; } + + /// + /// Force theme creation. + /// + [Option('f', "force", Required = false, HelpText = "Force theme creation")] + public bool Force { get; init; } + + /// + /// Theme title. + /// + [Option("title", Required = false, HelpText = "Theme title")] + public required string Title { get; init; } = "My Theme"; +} diff --git a/source/Models/ISite.cs b/source/Models/ISite.cs index 5868b54a3fc8bf117cdfa0641e7b9611c6539c87..fa40299be56c04e5030c9fab5a408c94d9ff8b09 100644 --- a/source/Models/ISite.cs +++ b/source/Models/ISite.cs @@ -2,6 +2,7 @@ using Fluid; using Serilog; using SuCoS.Helpers; using SuCoS.Models.CommandLineOptions; +using SuCoS.Parser; using System.Collections.Concurrent; namespace SuCoS.Models; @@ -9,27 +10,13 @@ namespace SuCoS.Models; /// /// The main configuration of the program, primarily extracted from the app.yaml file. /// -public interface ISite : IParams +public interface ISite : ISiteSettings, IParams { /// /// Command line options /// public IGenerateOptions Options { get; set; } - #region SiteSettings - - /// - /// Site Title. - /// - public string Title { get; } - - /// - /// The appearance of a URL is either ugly or pretty. - /// - public bool UglyURLs { get; } - - #endregion SiteSettings - /// /// The path of the content, based on the source path. /// @@ -45,11 +32,6 @@ public interface ISite : IParams /// public string SourceThemePath { get; } - /// - /// The path of the static content (that will be copied as is), based on the theme path. - /// - public string SourceThemeStaticPath => Path.Combine(SourceThemePath, "static"); - /// /// List of all pages, including generated. /// @@ -75,6 +57,11 @@ public interface ISite : IParams /// public SiteCacheManager CacheManager { get; } + /// + /// Metadata parser + /// + public IMetadataParser Parser { get; } + /// /// The Fluid parser instance. /// @@ -84,6 +71,7 @@ public interface ISite : IParams /// The Fluid/Liquid template options. /// public TemplateOptions TemplateOptions { get; } + /// /// The logger instance. /// diff --git a/source/Models/ISiteSettings.cs b/source/Models/ISiteSettings.cs new file mode 100644 index 0000000000000000000000000000000000000000..2bbda14fdd7b0e898d1bfe141c4f8c4083093fd8 --- /dev/null +++ b/source/Models/ISiteSettings.cs @@ -0,0 +1,32 @@ +namespace SuCoS.Models; + +/// +/// The main configuration of the program, extracted from the app.yaml file. +/// +public interface ISiteSettings : IParams +{ + /// + /// Site Title/Name. + /// + public string Title { get; } + + /// + /// Site description + /// + public string? Description { get; } + + /// + /// Copyright information + /// + public string? Copyright { get; } + + /// + /// The base URL that will be used to build public links. + /// + public string BaseURL { get; } + + /// + /// The appearance of a URL is either ugly or pretty. + /// + public bool UglyURLs { get; } +} diff --git a/source/Models/Site.cs b/source/Models/Site.cs index 264efbc78293ded3bdfc27ed2b7ba349ace3139a..53128c2892f630d66a093c76dec72facccb31aea 100644 --- a/source/Models/Site.cs +++ b/source/Models/Site.cs @@ -31,29 +31,19 @@ public class Site : ISite #region SiteSettings - /// - /// Site Title/Name - /// + /// public string Title => settings.Title; - /// - /// Site description - /// + /// public string? Description => settings.Description; - /// - /// Copyright information - /// + /// public string? Copyright => settings.Copyright; - /// - /// The base URL that will be used to build public links. - /// + /// public string BaseURL => settings.BaseURL; - /// - /// The appearance of a URL is either ugly or pretty. - /// + /// public bool UglyURLs => settings.UglyURLs; #endregion SiteSettings @@ -73,11 +63,6 @@ public class Site : ISite /// public string SourceThemePath => Path.Combine(Options.Source, settings.ThemeDir, settings.Theme ?? string.Empty); - /// - /// The path of the static content (that will be copied as is), based on the theme path. - /// - public string SourceThemeStaticPath => Path.Combine(SourceThemePath, "static"); - /// /// List of all basic source folders /// @@ -87,6 +72,11 @@ public class Site : ISite SourceThemePath ]; + /// + /// Theme used. + /// + public Theme? Theme { get; set; } + /// /// List of all pages, including generated. /// @@ -152,6 +142,9 @@ public class Site : ISite /// public int FilesParsedToReport => filesParsedToReport; + /// + public IMetadataParser Parser { get; init; } = new YAMLParser(); + private int filesParsedToReport; private const string indexLeafFileConst = "index.md"; @@ -163,11 +156,6 @@ public class Site : ISite /// private readonly object syncLockPostProcess = new(); - /// - /// The front matter parser instance. The default is YAML. - /// - private readonly IMetadataParser frontMatterParser; - private IEnumerable? pagesCache; private IEnumerable? regularPagesCache; @@ -191,15 +179,18 @@ public class Site : ISite Options = options; this.settings = settings; Logger = logger; - this.frontMatterParser = frontMatterParser; + Parser = frontMatterParser; // Liquid template options, needed to theme the content // but also parse URLs TemplateOptions.MemberAccessStrategy.Register(); TemplateOptions.MemberAccessStrategy.Register(); TemplateOptions.MemberAccessStrategy.Register(); + TemplateOptions.MemberAccessStrategy.Register(); this.clock = clock ?? new SystemClock(); + + Theme = Theme.CreateFromSite(this); } /// @@ -357,7 +348,7 @@ public class Site : ISite Page? page = null; try { - var frontMatter = frontMatterParser.ParseFrontmatterAndMarkdownFromFile(filePath, SourceContentPath) + var frontMatter = Parser.ParseFrontmatterAndMarkdownFromFile(filePath, SourceContentPath) ?? throw new FormatException($"Error parsing front matter for {filePath}"); if (IsValidPage(frontMatter, Options)) diff --git a/source/Models/SiteSettings.cs b/source/Models/SiteSettings.cs index 41b0b5f1d49c05335bb76d181d8ba19c060a1f94..796c0461deecbba2ffd954bbdac590b9232be742 100644 --- a/source/Models/SiteSettings.cs +++ b/source/Models/SiteSettings.cs @@ -6,8 +6,9 @@ namespace SuCoS.Models; /// The main configuration of the program, extracted from the app.yaml file. /// [YamlSerializable] -public class SiteSettings : IParams +public class SiteSettings : ISiteSettings { + #region ISiteSettings /// /// Site Title/Name. /// @@ -28,6 +29,8 @@ public class SiteSettings : IParams /// public string BaseURL { get; set; } = string.Empty; + #endregion ISiteSettings + /// /// The global site theme. /// diff --git a/source/Models/Theme.cs b/source/Models/Theme.cs new file mode 100644 index 0000000000000000000000000000000000000000..21ab95b5ad1ea32bcf1aa7cef907a4279f83c53f --- /dev/null +++ b/source/Models/Theme.cs @@ -0,0 +1,75 @@ +using YamlDotNet.Serialization; + +namespace SuCoS.Models; + +/// +/// Representation of the theme. +/// +[YamlSerializable] +public class Theme +{ + /// + /// Theme name + /// + public string Title { get; set; } = string.Empty; + + /// + /// Theme name + /// + [YamlIgnore] + public string Path { get; set; } = string.Empty; + + /// + /// The path of the static content (that will be copied as is) + /// + [YamlIgnore] + public string StaticFolder => System.IO.Path.Combine(Path, "static"); + + /// + /// folder that contains default layout files. + /// + [YamlIgnore] + public string DefaultLayoutFolder => System.IO.Path.Combine(Path, "_default"); + + /// + /// All default folders + /// + [YamlIgnore] + public IEnumerable Folders => [ + StaticFolder + ]; + + /// + /// Create a Theme from a given metadata content. + /// + /// + /// + /// + public static Theme Create(Site site, string data) + { + ArgumentNullException.ThrowIfNull(site); + + var theme = site.Parser.Parse(data); + theme.Path = site.SourceThemePath; + return theme; + } + + /// + /// Create a Theme from a given metadata file path. + /// + /// + /// + public static Theme? CreateFromSite(Site site) + { + ArgumentNullException.ThrowIfNull(site); + + var path = System.IO.Path.Combine(site.SourceThemePath, "sucos.yaml"); + + if (File.Exists(path)) + { + var data = File.ReadAllText(path); + return Create(site, data); + } + return null; + } +} diff --git a/source/NewSiteCommand.cs b/source/NewSiteCommand.cs index b46b99139292704efd2ed7709f5d92a6a24eab88..e2010712b21043a430b2aabf1cc2e2696c0e1cd5 100644 --- a/source/NewSiteCommand.cs +++ b/source/NewSiteCommand.cs @@ -1,7 +1,6 @@ using Serilog; using SuCoS.Models; using SuCoS.Models.CommandLineOptions; -using SuCoS.Parser; namespace SuCoS; @@ -41,8 +40,7 @@ public sealed partial class NewSiteCommand(NewSiteOptions options, ILogger logge try { - var parser = new YAMLParser(); - parser.Export(siteSettings, siteSettingsPath); + site.Parser.Export(siteSettings, siteSettingsPath); } catch (Exception ex) { diff --git a/source/NewThemeCommand.cs b/source/NewThemeCommand.cs new file mode 100644 index 0000000000000000000000000000000000000000..c138dc1de8c746e8dbfe8594b7697cb87520b3a2 --- /dev/null +++ b/source/NewThemeCommand.cs @@ -0,0 +1,67 @@ +using Serilog; +using SuCoS.Models; +using SuCoS.Models.CommandLineOptions; +using SuCoS.Parser; + +namespace SuCoS; + +/// +/// Check links of a given site. +/// +public sealed partial class NewThemeCommand(NewThemeOptions options, ILogger logger) +{ + /// + /// Run the app + /// + /// + public int Run() + { + var theme = new Theme() + { + Title = options.Title, + Path = options.Output + }; + var outputPath = Path.GetFullPath(options.Output); + var themePath = Path.Combine(outputPath, "sucos.yaml"); + + if (File.Exists(themePath) && !options.Force) + { + logger.Error("{directoryPath} already exists", outputPath); + return 1; + } + + logger.Information("Creating a new site: {title} at {outputPath}", theme.Title, outputPath); + + CreateFolders(theme.Folders); + + foreach (var themeFolder in theme.Folders) + + try + { + new YAMLParser().Export(theme, themePath); + } + catch (Exception ex) + { + logger.Error("Failed to export site settings: {ex}", ex); + return 1; + } + + logger.Information("Done"); + return 0; + } + + /// + /// Create the standard folders + /// + /// + private void CreateFolders(IEnumerable folders) + { + foreach (var folder in folders) + { + logger.Information("Creating {folder}", folder); + Directory.CreateDirectory(folder); + } + } + + +} \ No newline at end of file diff --git a/source/Parser/IMetadataParser.cs b/source/Parser/IMetadataParser.cs index 57c0db7812f89a7a2863cb48638d21d4fd1209ec..84d293089e8c46db6ecc3f46f8a5f0838d69b678 100644 --- a/source/Parser/IMetadataParser.cs +++ b/source/Parser/IMetadataParser.cs @@ -25,11 +25,11 @@ public interface IMetadataParser IFrontMatter? ParseFrontmatterAndMarkdown(in string fileFullPath, in string fileRelativePath, in string fileContent); /// - /// Parse the app config file. + /// Parse a string content to the T class. /// - /// + /// /// - SiteSettings ParseSiteSettings(string configFileContent); + T Parse(string content); /// /// Deserialized a object. diff --git a/source/Parser/YAMLParser.cs b/source/Parser/YAMLParser.cs index ac84d866284c390abd99a27a226867f31db6b33d..7c1fbd79f00a4ea0dfe142c54506b394578683a0 100644 --- a/source/Parser/YAMLParser.cs +++ b/source/Parser/YAMLParser.cs @@ -106,10 +106,10 @@ public class YAMLParser : IMetadataParser } /// - public SiteSettings ParseSiteSettings(string yaml) + public T Parse(string content) { - var settings = deserializer.Deserialize(yaml); - return settings; + var data = deserializer.Deserialize(content); + return data; } /// diff --git a/source/Program.cs b/source/Program.cs index be6664dd57dca8d2ae559cf0c17617f6c959af3d..53c15fff3197b68e0a0abffb8d56f0b36ba0169d 100644 --- a/source/Program.cs +++ b/source/Program.cs @@ -44,11 +44,13 @@ public class Program(ILogger logger) [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BuildOptions))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ServeOptions))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CheckLinkOptions))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NewSiteOptions))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NewThemeOptions))] public async Task RunCommandLine(string[] args) { OutputLogo(); OutputWelcome(); - return await CommandLine.Parser.Default.ParseArguments(args) + return await CommandLine.Parser.Default.ParseArguments(args) .WithParsed(options => { logger = CreateLogger(options.Verbose); @@ -103,6 +105,11 @@ public class Program(ILogger logger) { var command = new NewSiteCommand(options, logger); return Task.FromResult(command.Run()); + }, + (NewThemeOptions options) => + { + var command = new NewThemeCommand(options, logger); + return Task.FromResult(command.Run()); }, errs => Task.FromResult(0) ); diff --git a/source/ServeCommand.cs b/source/ServeCommand.cs index 4dacbaf25cabaf70d470a7ac59f2151ceb406fff..6d7757410ae5581427230523bf65b7d7baaf863d 100644 --- a/source/ServeCommand.cs +++ b/source/ServeCommand.cs @@ -89,7 +89,7 @@ public sealed class ServeCommand : BaseGeneratorCommand, IDisposable handlers = [ new PingRequests(), new StaticFileRequest(site.SourceStaticPath, false), - new StaticFileRequest(site.SourceThemeStaticPath, true), + new StaticFileRequest(site.Theme?.StaticFolder, true), new RegisteredPageRequest(site), new RegisteredPageResourceRequest(site) ]; @@ -161,7 +161,7 @@ public sealed class ServeCommand : BaseGeneratorCommand, IDisposable } // Reinitialize the site - site = SiteHelper.Init(configFile, options, frontMatterParser, WhereParamsFilter, logger, stopwatch); + site = SiteHelper.Init(configFile, options, Parser, WhereParamsFilter, logger, stopwatch); StartServer(baseURLDefault, portDefault); }).ConfigureAwait(false); diff --git a/source/ServerHandlers/StaticFileRequest.cs b/source/ServerHandlers/StaticFileRequest.cs index 1ca0203eadfddf70b0fc64a2ead2423f7beed83c..6703a150fe9b50beb58129502a1c727a3aaf87a3 100644 --- a/source/ServerHandlers/StaticFileRequest.cs +++ b/source/ServerHandlers/StaticFileRequest.cs @@ -7,7 +7,7 @@ namespace SuCoS.ServerHandlers; /// public class StaticFileRequest : IServerHandlers { - private readonly string basePath; + private readonly string? basePath; private readonly bool inTheme; /// @@ -15,7 +15,7 @@ public class StaticFileRequest : IServerHandlers /// /// /// - public StaticFileRequest(string basePath, bool inTheme) + public StaticFileRequest(string? basePath, bool inTheme) { this.basePath = basePath; this.inTheme = inTheme; @@ -26,6 +26,11 @@ public class StaticFileRequest : IServerHandlers { ArgumentNullException.ThrowIfNull(requestPath); + if (string.IsNullOrEmpty(basePath)) + { + return false; + } + var fileAbsolutePath = Path.Combine(basePath, requestPath.TrimStart('/')); return File.Exists(fileAbsolutePath); } @@ -36,7 +41,7 @@ public class StaticFileRequest : IServerHandlers ArgumentNullException.ThrowIfNull(requestPath); ArgumentNullException.ThrowIfNull(response); - var fileAbsolutePath = Path.Combine(basePath, requestPath.TrimStart('/')); + var fileAbsolutePath = Path.Combine(basePath!, requestPath.TrimStart('/')); response.ContentType = GetContentType(fileAbsolutePath!); await using var fileStream = new FileStream(fileAbsolutePath!, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); response.ContentLength64 = fileStream.Length; diff --git a/source/SuCoS.csproj b/source/SuCoS.csproj index 32eb4dcd8a28f9556a1009b2141a4d14eccc3549..3d3430d9cc5d2e8f2d9decf1d0084afb866eefc0 100644 --- a/source/SuCoS.csproj +++ b/source/SuCoS.csproj @@ -5,7 +5,7 @@ net8.0 enable enable - true + true true true diff --git a/test/Parser/YAMLParserTests.cs b/test/Parser/YAMLParserTests.cs index 8a4d577b096c1a420de667094c3d3f649c18b80b..dda4fa4cd32541505291fb3a0e1fc45f2062eacf 100644 --- a/test/Parser/YAMLParserTests.cs +++ b/test/Parser/YAMLParserTests.cs @@ -158,7 +158,7 @@ Title public void ParseSiteSettings_ShouldReturnSiteWithCorrectSettings() { // Act - var siteSettings = parser.ParseSiteSettings(siteContentCONST); + var siteSettings = parser.Parse(siteContentCONST); // Assert @@ -248,7 +248,7 @@ Title public void ParseSiteSettings_ShouldReturnSiteSettings() { // Arrange - var siteSettings = parser.ParseSiteSettings(siteContentCONST); + var siteSettings = parser.Parse(siteContentCONST); // Assert Assert.NotNull(siteSettings); @@ -277,7 +277,7 @@ Title public void SiteParams_ShouldPopulateParamsWithExtraFields() { // Arrange - var siteSettings = parser.ParseSiteSettings(siteContentCONST); + var siteSettings = parser.Parse(siteContentCONST); site = new Site(generateOptionsMock, siteSettings, frontMatterParser, loggerMock, systemClockMock); // Assert