refactor(EVERYTHING): Refactor all the things.
With a little bit of OOP and a monster commit, I too can make a an ssg, Ka-chow... Changes: - ➕ added class to represent SiteFile - ➕ enabled conversion semi-automatic based on file type. - ➕ added template to Testing. - ➖ Removed awful code for dependency search - ➖ Removed awful code for enumerating directory - ➕ arguments to a class to allow for easier passing to other parts of the code. TODO: - 🐞Test and debug with a copy of a live site, - ✍️ Add handling for Pandoc errors on stderr - ❓Look into parallelising as much as possible.
This commit is contained in:
parent
4ce8672a68
commit
18ed0534b7
370
Program.cs
370
Program.cs
|
|
@ -16,12 +16,15 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using Serilog;
|
||||
using System.CommandLine.Builder;
|
||||
using System.CommandLine.Help;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.CommandLine;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Reflection;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace csSiteGen;
|
||||
|
||||
|
|
@ -30,21 +33,6 @@ class Program
|
|||
|
||||
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
|
||||
string? version = Assembly.GetEntryAssembly()?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||
|
|
@ -52,7 +40,6 @@ class Program
|
|||
// Configure logger
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Information)
|
||||
.WriteTo.File("log.log")
|
||||
.CreateLogger();
|
||||
|
||||
|
|
@ -64,264 +51,173 @@ class Program
|
|||
}
|
||||
else
|
||||
{
|
||||
Log.Error("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;
|
||||
Log.Warning("Cannot get Version Information");
|
||||
}
|
||||
|
||||
// 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())
|
||||
{
|
||||
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
|
||||
string _inputDirectory = args[0];
|
||||
string _outputDirectory = args[1];
|
||||
Stopwatch TotalExecutionTime = Stopwatch.StartNew();
|
||||
|
||||
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);
|
||||
Environment.Exit(1);
|
||||
}
|
||||
if (!Directory.Exists(_outputDirectory))
|
||||
if (!result.GetValueForOption(inputDirectoryOption)!.Exists)
|
||||
{
|
||||
Log.Error("Output directory '{o}' Does not exist or is not a directory you have access to" , _outputDirectory);
|
||||
Environment.Exit(1);
|
||||
result.ErrorMessage = $"Input directory {result.GetValueForOption(inputDirectoryOption)!.FullName} does not exist";
|
||||
}
|
||||
});
|
||||
|
||||
// Test for dependencies
|
||||
List<string> deps = new List<string>{"pandoc"};
|
||||
var dep = CheckDeps(deps);
|
||||
Log.Debug("CheckDeps result: {@dep}",dep);
|
||||
var outputDirectoryOption = new Option<DirectoryInfo>(
|
||||
name: "--output",
|
||||
description: "The directory that the site should be output to.");
|
||||
outputDirectoryOption.IsRequired = true;
|
||||
|
||||
// Deal with the dependency test results
|
||||
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);
|
||||
}
|
||||
var rootCommand = new RootCommand("csSiteGen");
|
||||
|
||||
List<string> ConvertableInputFiles = new();
|
||||
foreach (var ext in _conversionTypes)
|
||||
var cleanCommand = new Command("clean", "Clean the output directory");
|
||||
cleanCommand.AddOption(outputDirectoryOption);
|
||||
cleanCommand.SetHandler(async (directory) =>
|
||||
{
|
||||
ConvertableInputFiles.AddRange(GetAllFilesMatching($".*{ext}",_inputDirectory));
|
||||
}
|
||||
Log.Debug("Files matching conversiontypes: {@conversionType} found {count} \n {files} ",_conversionTypes,ConvertableInputFiles.Count(),ConvertableInputFiles);
|
||||
await Task.Run(() =>
|
||||
{
|
||||
Clean(directory);
|
||||
});
|
||||
} ,outputDirectoryOption);
|
||||
|
||||
Dictionary<string,DateTime>? metadata = null;
|
||||
if (File.Exists($"{_inputDirectory}/.files"))
|
||||
var convertCommand = new Command("convert", "Convert the input directory and place the files in the output directory.");
|
||||
convertCommand.AddOption(inputDirectoryOption);
|
||||
convertCommand.AddOption(outputDirectoryOption);
|
||||
convertCommand.SetHandler(async (inputDir, outputDir) =>
|
||||
{
|
||||
Log.Information("Loading metadata for {src}",_inputDirectory);
|
||||
metadata = new();
|
||||
string metaJSON = File.ReadAllText($"{_inputDirectory}/.files");
|
||||
try
|
||||
await Task.Run(() =>
|
||||
{
|
||||
Type metadataType = metadata.GetType();
|
||||
metadata = JsonSerializer.Deserialize<Dictionary<string,DateTime>>(metaJSON);
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
Convert(inputDir, outputDir);
|
||||
});
|
||||
}, inputDirectoryOption, outputDirectoryOption);
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
// 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);
|
||||
rootCommand.AddCommand(cleanCommand);
|
||||
rootCommand.AddCommand(convertCommand);
|
||||
|
||||
var parser = new CommandLineBuilder(rootCommand)
|
||||
.UseDefaults()
|
||||
.Build();
|
||||
|
||||
parser.Invoke(args);
|
||||
|
||||
|
||||
TotalExecutionTime.Stop();
|
||||
Log.Information("TotalExecutionTime {time:000}ms", TotalExecutionTime.ElapsedMilliseconds);
|
||||
|
||||
Log.CloseAndFlush();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
static int Convert(DirectoryInfo inputDir, DirectoryInfo outputDir)
|
||||
{
|
||||
List<string> res = new();
|
||||
Stack<string> dirs = new();
|
||||
AnsiConsole.Console.Profile.Capabilities.Ansi = true;
|
||||
|
||||
dirs.Push(directory);
|
||||
Log.Debug("Dirs Starting as: {DirStack}",dirs );
|
||||
Stopwatch timer = new();
|
||||
timer.Start();
|
||||
while (dirs.Count > 0)
|
||||
List<SiteFile> siteFiles = new();
|
||||
|
||||
Utils.GetFiles(inputDir).ForEach(x => siteFiles.Add(new SiteFile(x)));
|
||||
Log.Information("SiteFiles: {@sf} {count}", siteFiles, siteFiles.Count);
|
||||
|
||||
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();
|
||||
res.AddRange(Directory.GetFiles(dir));
|
||||
foreach (string subdir in Directory.GetDirectories(dir))
|
||||
new TaskDescriptionColumn(),
|
||||
new ProgressBarColumn(),
|
||||
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++)
|
||||
{
|
||||
/*
|
||||
* FIXME: This is only a temporary measure and should not be relied upon
|
||||
* if possible there should be a depth detection system that allows for
|
||||
* safe symlink following.
|
||||
*/
|
||||
Log.Debug("Directory {subdir} is a ReparsePoint(symlink) and has been ignored",subdir);
|
||||
continue;
|
||||
tasks[i].MaxValue = 1;
|
||||
Log.Information("Converting file {name} {i}/{count}",siteFiles[i].Name,i+1,siteFiles.Count());
|
||||
bool res = siteFiles[i].Convert(settings);
|
||||
tasks[i].Increment(1);
|
||||
tasks[i].StopTask();
|
||||
if (!res)
|
||||
{
|
||||
Log.Warning("{name} Failed...",siteFiles[i].FullName);
|
||||
tasks[i].Description += " [red]FAILED[/]";
|
||||
}
|
||||
dirs.Push(subdir);
|
||||
Log.Debug("Adding Directory {subdir} to dirs\nResult: {dirs}",subdir,dirs);
|
||||
}
|
||||
}
|
||||
timer.Stop();
|
||||
Log.Information("Crawled {directory} in {time}ms with {number} results",directory,timer.ElapsedMilliseconds,res.Count());
|
||||
return res;
|
||||
Log.Information("adding {@siteFile} conversion status to fileStatus", siteFiles[i]);
|
||||
Log.Debug("FileStatus {@fileStatus}",fileStatus);
|
||||
fileStatus.Add(siteFiles[i].FullName,res);
|
||||
overallTask.Increment(1);
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
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()
|
||||
foreach (var fail in Failed)
|
||||
{
|
||||
/*
|
||||
* This function will be called when argument errors happen. This will
|
||||
* also be called if the user triggers the help flag currently this
|
||||
* 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);
|
||||
AnsiConsole.MarkupLineInterpolated(
|
||||
$"[red]File [blue]\"{fail.Key}\"[/] failed to convert[/]");
|
||||
AnsiConsole.MarkupLine("[yellow]See log for more details[/]");
|
||||
}
|
||||
}
|
||||
|
||||
if (success)
|
||||
{
|
||||
return new Tuple<bool,Dictionary<string,string>?>(success,result);
|
||||
}
|
||||
else
|
||||
{
|
||||
return new Tuple<bool,Dictionary<string,string>?>(success,failures);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool Convert(Dictionary<string,string> files)
|
||||
static int Clean(DirectoryInfo outputDir)
|
||||
{
|
||||
|
||||
return false;
|
||||
if (!outputDir.Exists)
|
||||
{
|
||||
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>
|
||||
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Serilog" Version="2.11.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
|
||||
<PackageReference Include="Serilog" Version="3.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<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>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user