Merge branch 'ReStructure'
This commit is contained in:
commit
4214af680a
370
Program.cs
370
Program.cs
|
|
@ -16,12 +16,15 @@
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
using Serilog;
|
|
||||||
using Serilog.Events;
|
using Serilog.Events;
|
||||||
using System.Reflection;
|
using Serilog;
|
||||||
using System.Text.RegularExpressions;
|
using System.CommandLine.Builder;
|
||||||
|
using System.CommandLine.Help;
|
||||||
|
using System.CommandLine.Parsing;
|
||||||
|
using System.CommandLine;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Text.Json;
|
using System.Reflection;
|
||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
namespace csSiteGen;
|
namespace csSiteGen;
|
||||||
|
|
||||||
|
|
@ -30,21 +33,6 @@ class Program
|
||||||
|
|
||||||
static int Main(string[] args)
|
static int Main(string[] args)
|
||||||
{
|
{
|
||||||
// Default values
|
|
||||||
List<string> _verbatimFileTypes = new List<string>{ // file extenstions we want to have copied verbatim
|
|
||||||
"html", // Premade html pages
|
|
||||||
"css", // Style Sheets
|
|
||||||
// IMAGE TYPES TODO: add webp conversion for most images.
|
|
||||||
"jpg",
|
|
||||||
"png",
|
|
||||||
"webp",
|
|
||||||
"gif"
|
|
||||||
};
|
|
||||||
// TODO: Make this a dictionary/tuple to show a FROM -> TO filetype relationship (Possibly with program)
|
|
||||||
List<string> _conversionTypes = new List<string>{ // file extenstions we want to convert to html
|
|
||||||
"md"
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// Get the current versiion number
|
// Get the current versiion number
|
||||||
string? version = Assembly.GetEntryAssembly()?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
string? version = Assembly.GetEntryAssembly()?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||||
|
|
@ -52,7 +40,6 @@ class Program
|
||||||
// Configure logger
|
// Configure logger
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
.MinimumLevel.Debug()
|
.MinimumLevel.Debug()
|
||||||
.WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Information)
|
|
||||||
.WriteTo.File("log.log")
|
.WriteTo.File("log.log")
|
||||||
.CreateLogger();
|
.CreateLogger();
|
||||||
|
|
||||||
|
|
@ -64,264 +51,173 @@ class Program
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Log.Error("Cannot get Version Information");
|
Log.Warning("Cannot get Version Information");
|
||||||
}
|
|
||||||
|
|
||||||
// Exit on argument errors
|
|
||||||
if (args.Length < 2)
|
|
||||||
{
|
|
||||||
Log.Error("Too Few Args: Expected at least 2 but received {Count}",args.Length);
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// It is very likely that this program will only work on linux. As such it is worth warning the user about this.
|
// It is very likely that this program will only work on linux. As such it is worth warning the user about this.
|
||||||
if (!OperatingSystem.IsLinux())
|
if (!OperatingSystem.IsLinux())
|
||||||
{
|
{
|
||||||
Log.Warning("This program has only been tested on linux and cannot be assumed to work on other Operating Systems");
|
Log.Warning("This program has only been tested on linux and cannot be assumed to work on other Operating Systems");
|
||||||
|
// AnsiConsole.MarkupLine("[[[yellow]Warning[/]]] This program is only tested on linux systems, it may not work on this Operating System.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get and check the directories passed to the program
|
Stopwatch TotalExecutionTime = Stopwatch.StartNew();
|
||||||
string _inputDirectory = args[0];
|
|
||||||
string _outputDirectory = args[1];
|
|
||||||
|
|
||||||
if (!Directory.Exists(_inputDirectory))
|
var inputDirectoryOption = new Option<DirectoryInfo>(
|
||||||
|
name: "--input",
|
||||||
|
description: "The directory that contains the site source.");
|
||||||
|
inputDirectoryOption.IsRequired = true;
|
||||||
|
inputDirectoryOption.AddValidator(result =>
|
||||||
{
|
{
|
||||||
Log.Error("Input directory '{i}' Does not exist or is not a directory you have access to" , _inputDirectory);
|
if (!result.GetValueForOption(inputDirectoryOption)!.Exists)
|
||||||
Environment.Exit(1);
|
|
||||||
}
|
|
||||||
if (!Directory.Exists(_outputDirectory))
|
|
||||||
{
|
{
|
||||||
Log.Error("Output directory '{o}' Does not exist or is not a directory you have access to" , _outputDirectory);
|
result.ErrorMessage = $"Input directory {result.GetValueForOption(inputDirectoryOption)!.FullName} does not exist";
|
||||||
Environment.Exit(1);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Test for dependencies
|
var outputDirectoryOption = new Option<DirectoryInfo>(
|
||||||
List<string> deps = new List<string>{"pandoc"};
|
name: "--output",
|
||||||
var dep = CheckDeps(deps);
|
description: "The directory that the site should be output to.");
|
||||||
Log.Debug("CheckDeps result: {@dep}",dep);
|
outputDirectoryOption.IsRequired = true;
|
||||||
|
|
||||||
// Deal with the dependency test results
|
var rootCommand = new RootCommand("csSiteGen");
|
||||||
Dictionary<string,string> depsDict = new();
|
|
||||||
if (dep.Item2 is null)
|
|
||||||
{
|
|
||||||
Log.Debug("Ignoring dependency check as no dependencies listed");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
depsDict = dep.Item2;
|
|
||||||
}
|
|
||||||
if (!dep.Item1)
|
|
||||||
{
|
|
||||||
foreach (var dependency in depsDict)
|
|
||||||
{
|
|
||||||
Log.Error("Dependency {dependency} Cannot be found\nPlease install it or check it is in your PATH",dependency.Key);
|
|
||||||
}
|
|
||||||
Log.CloseAndFlush();
|
|
||||||
Environment.Exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<string> ConvertableInputFiles = new();
|
var cleanCommand = new Command("clean", "Clean the output directory");
|
||||||
foreach (var ext in _conversionTypes)
|
cleanCommand.AddOption(outputDirectoryOption);
|
||||||
|
cleanCommand.SetHandler(async (directory) =>
|
||||||
{
|
{
|
||||||
ConvertableInputFiles.AddRange(GetAllFilesMatching($".*{ext}",_inputDirectory));
|
await Task.Run(() =>
|
||||||
}
|
{
|
||||||
Log.Debug("Files matching conversiontypes: {@conversionType} found {count} \n {files} ",_conversionTypes,ConvertableInputFiles.Count(),ConvertableInputFiles);
|
Clean(directory);
|
||||||
|
});
|
||||||
|
} ,outputDirectoryOption);
|
||||||
|
|
||||||
Dictionary<string,DateTime>? metadata = null;
|
var convertCommand = new Command("convert", "Convert the input directory and place the files in the output directory.");
|
||||||
if (File.Exists($"{_inputDirectory}/.files"))
|
convertCommand.AddOption(inputDirectoryOption);
|
||||||
|
convertCommand.AddOption(outputDirectoryOption);
|
||||||
|
convertCommand.SetHandler(async (inputDir, outputDir) =>
|
||||||
{
|
{
|
||||||
Log.Information("Loading metadata for {src}",_inputDirectory);
|
await Task.Run(() =>
|
||||||
metadata = new();
|
|
||||||
string metaJSON = File.ReadAllText($"{_inputDirectory}/.files");
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
Type metadataType = metadata.GetType();
|
Convert(inputDir, outputDir);
|
||||||
metadata = JsonSerializer.Deserialize<Dictionary<string,DateTime>>(metaJSON);
|
});
|
||||||
}
|
}, inputDirectoryOption, outputDirectoryOption);
|
||||||
catch (JsonException e)
|
|
||||||
{
|
|
||||||
Log.Debug(e,"Cannot deserialize .files Json data");
|
|
||||||
Log.Debug("As this is possibly caused by an internal interface change the file will be deleted");
|
|
||||||
File.Delete($"{_inputDirectory}/.files");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata is not null)
|
rootCommand.AddCommand(cleanCommand);
|
||||||
{
|
rootCommand.AddCommand(convertCommand);
|
||||||
// Use metadata to determine if files have been updated
|
|
||||||
// A shadow list is used here so we can iterate and modify at the same time
|
|
||||||
List<string> shadow = new List<string>(ConvertableInputFiles);
|
|
||||||
foreach (string file in shadow)
|
|
||||||
{
|
|
||||||
if (metadata[file] == new FileInfo(file).LastWriteTimeUtc)
|
|
||||||
{
|
|
||||||
Log.Debug("File {file} has not been updated since last run. ignoring",file);
|
|
||||||
ConvertableInputFiles.Remove(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.Information("{count} Files need converting",ConvertableInputFiles.Count());
|
|
||||||
Log.Debug("Files: {files}",ConvertableInputFiles);
|
|
||||||
|
|
||||||
|
var parser = new CommandLineBuilder(rootCommand)
|
||||||
|
.UseDefaults()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
parser.Invoke(args);
|
||||||
|
|
||||||
|
|
||||||
|
TotalExecutionTime.Stop();
|
||||||
|
Log.Information("TotalExecutionTime {time:000}ms", TotalExecutionTime.ElapsedMilliseconds);
|
||||||
|
|
||||||
Log.CloseAndFlush();
|
Log.CloseAndFlush();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
static int Convert(DirectoryInfo inputDir, DirectoryInfo outputDir)
|
||||||
/// Returns a list of all the files in the given directory and all subdirectories recursive
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Currently this function ignores reparse points (symlinks) so as to avoid infinite loops.
|
|
||||||
/// It is probably a good idea to make the function aware of loops so it can read symlinks.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="directory">
|
|
||||||
/// The directory to find files in.
|
|
||||||
/// <param>
|
|
||||||
/// <returns>
|
|
||||||
/// A string list of every file in the provided directory and all subdirectories
|
|
||||||
/// </returns>
|
|
||||||
static List<string> GetAllFiles(string directory)
|
|
||||||
{
|
{
|
||||||
List<string> res = new();
|
AnsiConsole.Console.Profile.Capabilities.Ansi = true;
|
||||||
Stack<string> dirs = new();
|
|
||||||
|
|
||||||
dirs.Push(directory);
|
List<SiteFile> siteFiles = new();
|
||||||
Log.Debug("Dirs Starting as: {DirStack}",dirs );
|
|
||||||
Stopwatch timer = new();
|
Utils.GetFiles(inputDir).ForEach(x => siteFiles.Add(new SiteFile(x)));
|
||||||
timer.Start();
|
Log.Information("SiteFiles: {@sf} {count}", siteFiles, siteFiles.Count);
|
||||||
while (dirs.Count > 0)
|
|
||||||
|
Console.WriteLine($"Converting {siteFiles.Count} files from {inputDir.FullName} to {outputDir.FullName}");
|
||||||
|
RuntimeSettings settings = new(inputDir,outputDir);
|
||||||
|
|
||||||
|
|
||||||
|
Dictionary<string,bool> fileStatus = new();
|
||||||
|
Log.Debug("fileStatus {@fileStatus}",fileStatus);
|
||||||
|
|
||||||
|
AnsiConsole.Progress()
|
||||||
|
.AutoRefresh(true)
|
||||||
|
.Columns(new ProgressColumn[]
|
||||||
{
|
{
|
||||||
var dir = dirs.Pop();
|
new TaskDescriptionColumn(),
|
||||||
res.AddRange(Directory.GetFiles(dir));
|
new ProgressBarColumn(),
|
||||||
foreach (string subdir in Directory.GetDirectories(dir))
|
new PercentageColumn(),
|
||||||
|
new RemainingTimeColumn(),
|
||||||
|
new SpinnerColumn()
|
||||||
|
})
|
||||||
|
.Start(ctx =>
|
||||||
{
|
{
|
||||||
if(File.GetAttributes(subdir).HasFlag(FileAttributes.ReparsePoint))
|
var tasks = siteFiles.Select(x => ctx.AddTask($"Converting {x.Name}")).ToList();
|
||||||
|
var overallTask = ctx.AddTask("[bold]Converting Files[/]");
|
||||||
|
overallTask.MaxValue = siteFiles.Count();
|
||||||
|
|
||||||
|
for (int i = 0; i < siteFiles.Count; i++)
|
||||||
{
|
{
|
||||||
/*
|
tasks[i].MaxValue = 1;
|
||||||
* FIXME: This is only a temporary measure and should not be relied upon
|
Log.Information("Converting file {name} {i}/{count}",siteFiles[i].Name,i+1,siteFiles.Count());
|
||||||
* if possible there should be a depth detection system that allows for
|
bool res = siteFiles[i].Convert(settings);
|
||||||
* safe symlink following.
|
tasks[i].Increment(1);
|
||||||
*/
|
tasks[i].StopTask();
|
||||||
Log.Debug("Directory {subdir} is a ReparsePoint(symlink) and has been ignored",subdir);
|
if (!res)
|
||||||
continue;
|
{
|
||||||
|
Log.Warning("{name} Failed...",siteFiles[i].FullName);
|
||||||
|
tasks[i].Description += " [red]FAILED[/]";
|
||||||
}
|
}
|
||||||
dirs.Push(subdir);
|
Log.Information("adding {@siteFile} conversion status to fileStatus", siteFiles[i]);
|
||||||
Log.Debug("Adding Directory {subdir} to dirs\nResult: {dirs}",subdir,dirs);
|
Log.Debug("FileStatus {@fileStatus}",fileStatus);
|
||||||
}
|
fileStatus.Add(siteFiles[i].FullName,res);
|
||||||
}
|
overallTask.Increment(1);
|
||||||
timer.Stop();
|
|
||||||
Log.Information("Crawled {directory} in {time}ms with {number} results",directory,timer.ElapsedMilliseconds,res.Count());
|
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
static List<string> GetAllFilesMatching(string pattern, string directory)
|
Log.Information("Conversion Status {@status}", fileStatus);
|
||||||
|
|
||||||
|
var Failed = fileStatus.Where(x => x.Value == false);
|
||||||
|
if ( Failed.Count() > 0)
|
||||||
{
|
{
|
||||||
List<string> res = new();
|
foreach (var fail in Failed)
|
||||||
List<string> files = GetAllFiles(directory);
|
|
||||||
|
|
||||||
Regex expression = new Regex(pattern,
|
|
||||||
RegexOptions.Compiled);
|
|
||||||
|
|
||||||
res = files.FindAll( x => expression.IsMatch(x)); // Here a lambda function is used as a predicate to do regex across the whole file list
|
|
||||||
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Usage()
|
|
||||||
{
|
{
|
||||||
/*
|
AnsiConsole.MarkupLineInterpolated(
|
||||||
* This function will be called when argument errors happen. This will
|
$"[red]File [blue]\"{fail.Key}\"[/] failed to convert[/]");
|
||||||
* also be called if the user triggers the help flag currently this
|
AnsiConsole.MarkupLine("[yellow]See log for more details[/]");
|
||||||
* function throws a not implemented exception since it I cannot write
|
|
||||||
* the usage until I have other functionality
|
|
||||||
*/
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
static Tuple<bool,Dictionary<string,string>?> CheckDeps(List<string>? dependencies)
|
|
||||||
{
|
|
||||||
bool success = true;
|
|
||||||
Stopwatch timer = new();
|
|
||||||
if (! (dependencies?.Count() > 0)) // assume success as no dependencies
|
|
||||||
{
|
|
||||||
return new Tuple<bool,Dictionary<string,string>?>(true,null);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
var path = System.Environment.GetEnvironmentVariable("PATH")?.Split(':');
|
|
||||||
if (path is null)
|
|
||||||
{
|
|
||||||
Log.Error("Cannot read system path.");
|
|
||||||
Environment.Exit(1);
|
|
||||||
}
|
|
||||||
Log.Debug("Path is: {path}",path);
|
|
||||||
|
|
||||||
List<string> executables = new();
|
|
||||||
timer = Stopwatch.StartNew();
|
|
||||||
foreach (string dir in path)
|
|
||||||
{
|
|
||||||
if (Directory.Exists(dir)) // Sometimes People fuck up their path variables and include directories that don't exist.
|
|
||||||
{
|
|
||||||
executables.AddRange(GetAllFiles(dir));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
timer.Stop();
|
|
||||||
Log.Verbose("Executables are: {executables}",executables);
|
|
||||||
Log.Information("Got executables list in {timer:000}ms",timer.ElapsedMilliseconds);
|
|
||||||
|
|
||||||
// Do some regex magic to find an executable in the path.
|
|
||||||
Dictionary<string,string>? result = new();
|
|
||||||
Dictionary<string,string>? failures = new();
|
|
||||||
|
|
||||||
foreach (string dependency in dependencies)
|
|
||||||
{
|
|
||||||
timer = Stopwatch.StartNew();
|
|
||||||
string regex = $"/(.*/)*{dependency}";
|
|
||||||
Regex expr = new Regex(regex,RegexOptions.Compiled);
|
|
||||||
Log.Debug("Compiled regex: {expr}",expr);
|
|
||||||
string? match = executables.Find( x => expr.IsMatch(x));
|
|
||||||
if (match is not null && match != "")
|
|
||||||
{
|
|
||||||
timer.Stop();
|
|
||||||
Log.Information("Dependency {dep} found at {path} in {timer:000}ms",dependency,match,timer.ElapsedMilliseconds);
|
|
||||||
result.Add(dependency,match); // Create a dictionary of paths to found executables
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
timer.Stop();
|
|
||||||
Log.Information("Dependency {dep} cannot be found, search took {timer:000ms}",dependency,timer.ElapsedMilliseconds);
|
|
||||||
success = false;
|
|
||||||
failures.Add(dependency,dependency);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success)
|
return 0;
|
||||||
{
|
|
||||||
return new Tuple<bool,Dictionary<string,string>?>(success,result);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return new Tuple<bool,Dictionary<string,string>?>(success,failures);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool Convert(Dictionary<string,string> files)
|
static int Clean(DirectoryInfo outputDir)
|
||||||
{
|
{
|
||||||
|
if (!outputDir.Exists)
|
||||||
return false;
|
{
|
||||||
|
Log.Warning("Not deleting {dir} as it doesn't exist",outputDir.FullName);
|
||||||
|
AnsiConsole.MarkupLineInterpolated($"[bold][[[yellow]Warning[/]]][/] Not cleaning [blue]\"{outputDir}\"[/] as it does not exist.");
|
||||||
|
return 0; // success because it doesn't exist.
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Log.Information("Cleaning {dir}",outputDir.FullName);
|
||||||
|
AnsiConsole.MarkupInterpolated($"Cleaning [blue]\"{outputDir.FullName}\"[/]");
|
||||||
|
outputDir.Delete(recursive: true);
|
||||||
|
outputDir.Create();
|
||||||
|
AnsiConsole.MarkupLine(" [bold][[[green]OK[/]]][/]");
|
||||||
|
AnsiConsole.MarkupLineInterpolated($"\t[grey]>>[/] [green]All files in {outputDir.FullName} purged successfully[/]");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (System.Security.SecurityException e)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine(" [bold][[[red]Fail[/]]][/]");
|
||||||
|
AnsiConsole.MarkupLine("[orangered1]See log for more details about what went wrong.[/]");
|
||||||
|
Log.Error(e, "Failed to delete directory {dir} due to permission error.", outputDir.FullName);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine(" [red][[[bold]Fail[/]]][/]");
|
||||||
|
AnsiConsole.MarkupLine("[orangered1]See log for more details about what went wrong.[/]");
|
||||||
|
Log.Error(e, "Failed to delete/create directory {dir}", outputDir.FullName);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// if (!File.Exists($"{_inputDirectory}/.files"))
|
|
||||||
// {
|
|
||||||
// Log.Information("Creating metadata for {src}",_inputDirectory);
|
|
||||||
// metadata = new();
|
|
||||||
// foreach (var file in ConvertableInputFiles)
|
|
||||||
// {
|
|
||||||
// FileInfo info = new FileInfo(file);
|
|
||||||
// DateTime LastMod = info.LastWriteTimeUtc;
|
|
||||||
// metadata.Add(file,LastMod);
|
|
||||||
// string metaJSON = JsonSerializer.Serialize(metadata);
|
|
||||||
// File.WriteAllText($"{_inputDirectory}/.files",metaJSON);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
|
||||||
29
RuntimeSettings/RuntimeSettings.cs
Normal file
29
RuntimeSettings/RuntimeSettings.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
namespace csSiteGen;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Class <c>RuntimeSettings</p> Contains all the settings that could be loaded from the commandline.
|
||||||
|
/// </summary>
|
||||||
|
public class RuntimeSettings {
|
||||||
|
public DirectoryInfo InputDirectory {get; private set;}
|
||||||
|
public DirectoryInfo OutputDirectory {get; private set;}
|
||||||
|
|
||||||
|
|
||||||
|
public RuntimeSettings(string inputDirectory, string outputDirectory){
|
||||||
|
InputDirectory = new DirectoryInfo(inputDirectory);
|
||||||
|
OutputDirectory = new DirectoryInfo(outputDirectory);
|
||||||
|
|
||||||
|
/* NOTE: it is the responisbility of the UI code to check the values passed are good.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public RuntimeSettings(DirectoryInfo inputDirectory, DirectoryInfo outputDirectory){
|
||||||
|
InputDirectory = inputDirectory;
|
||||||
|
OutputDirectory = outputDirectory;
|
||||||
|
|
||||||
|
/* NOTE: it is the responisbility of the UI code to check the values passed are good.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
158
SiteFile/SiteFile.ConverterFunctions.cs
Normal file
158
SiteFile/SiteFile.ConverterFunctions.cs
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
using Serilog;
|
||||||
|
using Spectre.Console;
|
||||||
|
using System.Diagnostics;
|
||||||
|
namespace csSiteGen;
|
||||||
|
|
||||||
|
public static class Conversions{
|
||||||
|
|
||||||
|
|
||||||
|
public delegate bool ConvertFunc(FileInfo file, RuntimeSettings settings);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Mapping of filetype to ConvertFunc.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Dictionary<string,ConvertFunc> Mappings = new(){
|
||||||
|
{".md", Pandoc},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TEST FUNCTION.
|
||||||
|
/// </summary>
|
||||||
|
public static bool NoOp(FileInfo file, RuntimeSettings settings){
|
||||||
|
Log.Information("Performing NoOp Conversion");
|
||||||
|
string newName = GetNewName(file,settings,"NoOp");
|
||||||
|
Log.Debug("{FullName} -> {newName}",file.FullName,newName);
|
||||||
|
Thread.Sleep(1500);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copy the file verbatim
|
||||||
|
/// </summary>
|
||||||
|
public static bool RawCpy(FileInfo file, RuntimeSettings settings){
|
||||||
|
FileInfo newPath = new FileInfo(GetNewName(file,settings,null));
|
||||||
|
|
||||||
|
Log.Information("RawCpy: Copying {file} to {dest}",file.FullName, newPath.FullName);
|
||||||
|
|
||||||
|
if (!newPath.Directory!.Exists)
|
||||||
|
{
|
||||||
|
newPath.Directory.Create();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
file.CopyTo(newPath.FullName, overwrite: true);
|
||||||
|
}
|
||||||
|
catch (Exception e){
|
||||||
|
Log.Fatal(e,"Copy Failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Execute pandoc on the file, automatically detecting the template to use.
|
||||||
|
/// </summary>
|
||||||
|
public static bool Pandoc(FileInfo file, RuntimeSettings settings){
|
||||||
|
Log.Information("Attempting to convert {file} using pandoc",file.Name);
|
||||||
|
|
||||||
|
// Look for pandoc
|
||||||
|
string pandoc = Utils.PathSearch("pandoc");
|
||||||
|
if (string.IsNullOrEmpty(pandoc))
|
||||||
|
{
|
||||||
|
Console.WriteLine("Conversion failed due to dependency being unavailable.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Log.Information("Located pandoc binary.");
|
||||||
|
|
||||||
|
// Look for template file.
|
||||||
|
FileInfo? template = null;
|
||||||
|
DirectoryInfo? searchDir = file.Directory;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
// This loop starts searching at the directory of the file,
|
||||||
|
// and if a template is not found works up to the InputDirectory.
|
||||||
|
// If no template is found at any level we simply run pandoc with no template.
|
||||||
|
|
||||||
|
if (searchDir is null) // While it is unlikely this could happen the check is here to please the compiler.
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = searchDir.GetFiles(".template");
|
||||||
|
if (result.Length > 0)
|
||||||
|
{
|
||||||
|
template = result.First();
|
||||||
|
Log.Information("Found template {template}", template);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go up the tree.
|
||||||
|
searchDir = searchDir.Parent;
|
||||||
|
} while (searchDir != settings.InputDirectory); // Check last as we want to search the InputDirectory
|
||||||
|
|
||||||
|
string pandocArgs = $"{file.FullName} -o {GetNewName(file,settings,".html")}";
|
||||||
|
|
||||||
|
if (template is not null)
|
||||||
|
{
|
||||||
|
pandocArgs += $" --template={template.FullName}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[bold][[[orange1] Warning [/]]][/] Pandoc Template was not located.");
|
||||||
|
Log.Warning("Pandoc template for {file} not found",file.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RunExternalProgram(pandoc,pandocArgs);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RunExternalProgram(string program, string args)
|
||||||
|
{
|
||||||
|
|
||||||
|
Log.Information("Executing {program}", program);
|
||||||
|
Log.Debug("Full arguments {args}",args);
|
||||||
|
|
||||||
|
using (Process RunProgram = new())
|
||||||
|
{
|
||||||
|
// Reading stderr and stdout needs to be done carefully.
|
||||||
|
string? stdout = null;
|
||||||
|
string? stderr = null;
|
||||||
|
|
||||||
|
RunProgram.StartInfo.UseShellExecute = false;
|
||||||
|
RunProgram.StartInfo.FileName = program;
|
||||||
|
RunProgram.StartInfo.CreateNoWindow = true;
|
||||||
|
RunProgram.StartInfo.Arguments = args;
|
||||||
|
RunProgram.StartInfo.RedirectStandardOutput = true;
|
||||||
|
RunProgram.StartInfo.RedirectStandardError = true;
|
||||||
|
|
||||||
|
// Add a handler to append stderr to the stderr string
|
||||||
|
RunProgram.ErrorDataReceived += new DataReceivedEventHandler((sender, e) =>
|
||||||
|
{stderr += e.Data;});
|
||||||
|
RunProgram.OutputDataReceived += new DataReceivedEventHandler((sender, o) =>
|
||||||
|
{stdout += o.Data;});
|
||||||
|
|
||||||
|
RunProgram.Start();
|
||||||
|
RunProgram.BeginErrorReadLine();
|
||||||
|
RunProgram.BeginOutputReadLine();
|
||||||
|
|
||||||
|
RunProgram.WaitForExit();
|
||||||
|
|
||||||
|
Log.Debug("{program} stdout {stdout}", program, stdout);
|
||||||
|
Log.Debug("{program} stderr {stderr}", program, stderr);
|
||||||
|
|
||||||
|
if (RunProgram.ExitCode != 0)
|
||||||
|
{
|
||||||
|
Log.Error("{program} execution Failed. Check debug data for more information", program);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetNewName(FileInfo file, RuntimeSettings settings, string? newExtension){
|
||||||
|
return file.FullName
|
||||||
|
.Replace(settings.InputDirectory.FullName, settings.OutputDirectory.FullName)
|
||||||
|
.Replace(file.Extension,newExtension ?? file.Extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
SiteFile/SiteFile.cs
Normal file
138
SiteFile/SiteFile.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using Serilog;
|
||||||
|
namespace csSiteGen;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <c>SiteFile</c> represents an individual file to be converted for the static site.
|
||||||
|
/// </summary>
|
||||||
|
public partial class SiteFile
|
||||||
|
{
|
||||||
|
FileInfo info;
|
||||||
|
Conversions.ConvertFunc ConverterFunction;
|
||||||
|
static Dictionary<string, DateTime>? Metadata = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the file, Not Guaranteed to be unique.
|
||||||
|
/// Use only for output and logging, never file operations,
|
||||||
|
/// nor as a Dictionary key.
|
||||||
|
/// </summary>
|
||||||
|
public string Name => info.Name;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The FullName (or Path) of the file, This is unique as each file can only be found once.
|
||||||
|
/// Use for file operations and Dictionary keys, or anywhere else you need to avoid ambiguity.
|
||||||
|
/// </summary>
|
||||||
|
public string FullName => info.FullName;
|
||||||
|
|
||||||
|
public SiteFile(FileInfo fileInfo)
|
||||||
|
{
|
||||||
|
info = fileInfo;
|
||||||
|
|
||||||
|
Log.Information("{file} extension is {ext}",fileInfo.FullName, fileInfo.Extension);
|
||||||
|
// Using this Ensures that the ConverterFunction is Always set.
|
||||||
|
// ConverterFunctions ALWAYS accept just the FileInfo, and RuntimeSettings passed at convert time.
|
||||||
|
ConverterFunction = Conversions.Mappings.GetValueOrDefault(info.Extension, Conversions.RawCpy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert the file, placing it in the correct place in the output directory.
|
||||||
|
/// If a filetype conversion is not needed, or specified, then the file is simply copied.
|
||||||
|
/// </summary>
|
||||||
|
public bool Convert(RuntimeSettings settings)
|
||||||
|
{
|
||||||
|
if (!NeedsUpdating(settings))
|
||||||
|
{
|
||||||
|
Log.Information("Ignoring {name}, as it has not been changed since last run.", info.FullName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bool res = ConverterFunction(info, settings);
|
||||||
|
|
||||||
|
if (res)
|
||||||
|
{
|
||||||
|
Log.Information("Converted sucessfully, updating metadata");
|
||||||
|
if (Metadata!.ContainsKey(info.FullName))
|
||||||
|
{
|
||||||
|
Metadata[info.FullName] = info.LastWriteTimeUtc;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Metadata.Add(info.FullName,info.LastWriteTimeUtc);
|
||||||
|
}
|
||||||
|
Log.Debug("Metadata Dictionary now {@Metadata}", Metadata);
|
||||||
|
SaveMetadata(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool NeedsUpdating(RuntimeSettings settings)
|
||||||
|
{
|
||||||
|
if (Metadata is null)
|
||||||
|
{
|
||||||
|
LoadMetadata(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: By this point Metadata CANNOT be null as LoadMetadata would either have loaded the JSON or instantiated MetaData
|
||||||
|
if (Metadata!.GetValueOrDefault(info.FullName, DateTime.MinValue) == info.LastWriteTimeUtc)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* NOTE: It may seem wrong to save the metadata in the output directory.
|
||||||
|
* But that ensures that if you remove the output directory the site will be
|
||||||
|
* Fully recreated.
|
||||||
|
*/
|
||||||
|
private void LoadMetadata(RuntimeSettings settings)
|
||||||
|
{
|
||||||
|
string metaFile = $"{settings.OutputDirectory}/.files";
|
||||||
|
Log.Information("Loading Metadata from {file}",metaFile);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string metaJson = File.ReadAllText(metaFile);
|
||||||
|
Log.Debug("Read Json {metaJson}", metaJson);
|
||||||
|
Metadata = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(metaJson);
|
||||||
|
Log.Debug("Deserialized to {@Metadata}",Metadata);
|
||||||
|
}
|
||||||
|
catch (IOException e)
|
||||||
|
{
|
||||||
|
Log.Warning(e,"Error reading .files metafile");
|
||||||
|
Metadata = new();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (JsonException e)
|
||||||
|
{
|
||||||
|
Log.Information(e, "Failed to deserialize .files Json data");
|
||||||
|
Log.Information("The metadata file will be deleted as it is likely corrupted.");
|
||||||
|
File.Delete(metaFile);
|
||||||
|
Metadata = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveMetadata(RuntimeSettings settings)
|
||||||
|
{
|
||||||
|
if (Metadata is null)
|
||||||
|
{
|
||||||
|
Log.Warning("Attempted to save null Metadata");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
string metaFile = $"{settings.OutputDirectory}/.files";
|
||||||
|
string metaJson;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
metaJson = JsonSerializer.Serialize<Dictionary<string, DateTime>>(Metadata);
|
||||||
|
Log.Debug("metaJson: {metaJson}",metaJson);
|
||||||
|
}
|
||||||
|
catch (JsonException e)
|
||||||
|
{
|
||||||
|
Log.Warning(e, "Failed to serialize .files Json data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.Information("Writing metadata");
|
||||||
|
File.WriteAllText(metaFile, metaJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
TODO
Normal file
5
TODO
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
- Add a pre-commit or other type of hook to ensure that the Testing directory is properly cleaned and reset before commit.
|
||||||
|
- Double check that links are processed correctly by testing against a copy of my blog source.
|
||||||
|
- Also add the appropriate metadata to the markdown files to make sure that they are properly detected.
|
||||||
|
- Potentially host a local Test version to fully ensure that everything can be made to work properly.
|
||||||
|
- Look into the possiblity of using a TemplateTemplate, And generating the pandoc template on the fly making it possible to switch out domian/prefix stuff
|
||||||
92
Testing/src/.template
Executable file
92
Testing/src/.template
Executable file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="$lang$" xml:lang="$lang$"$if(dir)$ dir="$dir$"$endif$>
|
||||||
|
<head>
|
||||||
|
$-- #### STATIC HEAD ELEMENTS
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="generator" content="pandoc" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=yes" />
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:site_name" content="Robert's Place">
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<link rel="shortcut icon" href="https://www.closedless.xyz/~robert/favicon.png" type="image/png">
|
||||||
|
<link rel="stylesheet" href="https://www.closedless.xyz/~robert/css/blog.css" type="text/css" media="all">
|
||||||
|
$-- #### VARIABLE HEAD ELEMENTS
|
||||||
|
<title>$if(title-prefix)$$title-prefix$ – $endif$$pagetitle$</title>
|
||||||
|
<meta property="og:title" content="$title$"/>
|
||||||
|
<meta property="og:url" content="http://euro-travel-example.com/index.htm" />
|
||||||
|
$for(author-meta)$ $-- Include author information
|
||||||
|
<meta name="author" content="$author-meta$" />
|
||||||
|
$endfor$
|
||||||
|
$if(date-meta)$ $-- Include date information
|
||||||
|
<meta name="dcterms.date" content="$date-meta$" />
|
||||||
|
$endif$
|
||||||
|
$if(keywords)$ $-- Include Keywords
|
||||||
|
<meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$" />
|
||||||
|
$endif$
|
||||||
|
$if(description-meta)$ $-- Include description
|
||||||
|
<meta name="description" content="$description-meta$" />
|
||||||
|
<meta property="og:description" content="$description-meta$">
|
||||||
|
$endif$
|
||||||
|
$if(image)$ $-- OPTIONAL set image for link cards
|
||||||
|
<meta property="og:image" content="$image$" />
|
||||||
|
$else$ $-- Defaults to favicon
|
||||||
|
<meta property="og:image" content="https://closedless.xyz/~robert/favicon.png">
|
||||||
|
$endif$
|
||||||
|
$if(alt-text)$ $-- OPTIONAL Include alt text for twitter
|
||||||
|
<meta name="twitter:image:alt" content="$alt-text$">
|
||||||
|
$endif$
|
||||||
|
|
||||||
|
$if(math)$ $-- I think this includes some kind of css
|
||||||
|
$math$
|
||||||
|
$endif$
|
||||||
|
|
||||||
|
$for(header-includes)$ $-- Include any external data
|
||||||
|
$header-includes$
|
||||||
|
$endfor$
|
||||||
|
|
||||||
|
$if(needs-syntax-highlighting)$ $-- If set then include css and JS for syntax highlighting
|
||||||
|
<link rel="stylesheet" href="https://closedless.xyz/resources/CSS/hljs-gruvbox-CSSVARS.css" type="text/css" media="all">
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/highlight.min.js"></script>
|
||||||
|
$endif$
|
||||||
|
$if(style)$ $-- Include page specfic style (To reduce size of page load on other pages)
|
||||||
|
<style>$style$</style>
|
||||||
|
$endif$
|
||||||
|
</head>
|
||||||
|
<body class="sans"> $-- default to sans-serif --$
|
||||||
|
<nav class="navbar mono"> $-- navbar in monospace --$
|
||||||
|
<div class="bar-left">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://www.closedless.xyz/~robert">
|
||||||
|
<img class="minipic" src="https://www.closedless.xyz/~robert/favicon.png">
|
||||||
|
<span class="site-title">Robert's Place</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="bar-right">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a id="dark-toggle" class="nf nf-fa-moon_o" onclick="changeTheme()"></a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://instagram.com/sherlock5512" class="nf nf-mdi-instagram"> Instagram</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
$body$
|
||||||
|
<footer class="Ilow">
|
||||||
|
<p>
|
||||||
|
Design based on <a href="https://notthebe.ee/">Wolfgang's blog</a> (<a href="https://creativecommons.org/licenses/by-nc/4.0">CC-BY-NC 4.0</a>)
|
||||||
|
<br>
|
||||||
|
Pages generated from markdown from markdown using <a class="mono" href="https://romanzolotarev.com/ssg.html">ssg5</a> by Roman Zolotarev
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
<script charset="utf-8">
|
||||||
|
$if(needs-syntax-highlighting)$hljs.highlightAll();$endif$
|
||||||
|
console.log("TEST");
|
||||||
|
initLoad();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
Utils/Utils.GetFiles.cs
Normal file
9
Utils/Utils.GetFiles.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace csSiteGen;
|
||||||
|
|
||||||
|
public static partial class Utils {
|
||||||
|
|
||||||
|
// Abstract Directory.GetFiles to get a List as it will be easier to handle later.
|
||||||
|
public static List<FileInfo> GetFiles(DirectoryInfo dir){
|
||||||
|
return dir.GetFiles("*",SearchOption.AllDirectories).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Utils/Utils.PathSearch.cs
Normal file
52
Utils/Utils.PathSearch.cs
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
using Serilog;
|
||||||
|
namespace csSiteGen;
|
||||||
|
|
||||||
|
public static partial class Utils {
|
||||||
|
|
||||||
|
static Dictionary<string,string> PathSearchMemo = new();
|
||||||
|
|
||||||
|
///<summary>
|
||||||
|
/// Find executable in path
|
||||||
|
///</summary>
|
||||||
|
public static string PathSearch(string Program){
|
||||||
|
|
||||||
|
if (PathSearchMemo.TryGetValue(Program, out string? result))
|
||||||
|
{
|
||||||
|
Log.Debug("Memo Hit for {program}", Program);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = System.Environment.GetEnvironmentVariable("PATH")?.Split(':');
|
||||||
|
if (path is null)
|
||||||
|
{
|
||||||
|
Log.Error("Failed to read PATH environment variable.");
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<string> candidateExecutables = new();
|
||||||
|
|
||||||
|
foreach (var dir in path)
|
||||||
|
{
|
||||||
|
// Directories do not need to exist for them to be in path
|
||||||
|
// This check avoids attempting to look in directories that
|
||||||
|
// Do not exist.
|
||||||
|
if (Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
Log.Information("Searching for {Program} in {dir}", Program, dir);
|
||||||
|
candidateExecutables.AddRange(Directory.GetFiles(dir,$"{Program}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateExecutables.Count == 0)
|
||||||
|
{
|
||||||
|
Log.Warning("Dependency {Program} not found",Program);
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = candidateExecutables.First();
|
||||||
|
PathSearchMemo.Add(Program,result);
|
||||||
|
Log.Information("Found {program} at {path}",Program, result);
|
||||||
|
Log.Debug("Adding {@entry} to PathSearchMemo", PathSearchMemo.Last());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,15 +10,17 @@
|
||||||
|
|
||||||
<OutputPath>bin\$(Configuration)\</OutputPath>
|
<OutputPath>bin\$(Configuration)\</OutputPath>
|
||||||
|
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Serilog" Version="2.11.0" />
|
<PackageReference Include="Serilog" Version="3.1.1" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||||
|
<PackageReference Include="spectre.console" Version="0.48.0" />
|
||||||
|
<PackageReference Include="system.commandline" Version="2.0.0-beta4.22272.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user