2020 has been a personal milestone year of blogging for me. I’m proud of what I’ve been able to accomplish and humbled by the many folks who read and promote my work. Thank you to all my champions! In a show of gratitude, I want to share my blogging workflow with you, my readers. In a previous post, I talked about using Thor, a ruby library for performing basic tasks like creating a new post, scheduling an upcoming post on a schedule, and outputting general diagnostic about my current writing.
We’ll look at my current command line actions so folks can build their custom supercharged writing workflow.
Prerequisites
I’ve taken the liberty of creating a repo where you can get started with the code found in this post. Fork it and enjoy the polyglot madness!.
For first-time folks, as of writing this post, this blog is powered by Jekyll, a static site generator. It might not be the newest tool on the block, but I find it generally stays out of my way when writing. Markdown support is top-notch, and plugins are aplenty.
The idea behind my workflow is to lean on Jekyll for static site generation but to automate most of the tedious work of managing files, thinking about publishing dates, and in the future to expand functionality.
If you don’t have a current blog, I recommend the following setup.
- Platform: Ruby
- Platform: Jekyll
- Platform: .NET
- Package: Oakton
- Package: Spectre.Console
- Package: SimpleExec
After installing Ruby and .NET, folks can create a starter project with the following commands in a terminal.
> take my-blog
> gem install bundler jekyll
> jekyll new .
> dotnet new console
> dotnet add package Oakton
> dotnet add package SimpleExec
> dotnet add package Spectre.Console
Congratulations! You now have a Frankenproject of Ruby and .NET. Now we can start looking at some of the code that I use to power this blog.
You will also need to exclude
files from the Jekyll build pipeline, or Jekyll may attempt to copy our C# files to the final target folder.
exclude:
- README.md
- horace_readme.md
- jekyll.thor
- netlify.toml
- run
- Changelog.md
- questions.md
- "*.cs"
- "*.csproj"
- /bin
- /obj
- /.idea
- Commands
include:
- _redirects
Blog Helpers
.NET developers know that our .NET apps’ working directory is within the bin
directory. For Jekyll sites, all the essential files sit at the root of our initial directory. To make commands work, we need to set up a Settings
class. The first significant helper methods are to change our work on the files in our Jekyll blog.
private static Lazy<string> BlogDirectory => new Lazy<string>(() => {
var current = typeof(Program).Assembly.Location;
var index = current.IndexOf("/bin", StringComparison.Ordinal);
return current.Substring(0, index);
});
public static string GetDirectory(string folder)
=> Path.Combine(CurrentDirectory, folder);
public static string CurrentDirectory => BlogDirectory.Value;
Great! I have more settings specific to my blog, but these properties are foundational values. The Blog
class holds helper methods to perform the following actions:
- Retrieve All The Posts From our
_posts
directory - Get The latest blog post
- Get The next publish date, based on my
Tuesday
andThursday
schedule - Create a new post file
Here is the code for working with posts. Folks should modify these helpers to match their writing schedule and update the Jekyll front matter to fit their particular Jekyll theme.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace blog.commands
{
public static class Settings
{
private static Lazy<string> BlogDirectory => new Lazy<string>(() => {
var current = typeof(Program).Assembly.Location;
var index = current.IndexOf("/bin", StringComparison.Ordinal);
return current.Substring(0, index);
});
public static string GetDirectory(string folder)
=> Path.Combine(CurrentDirectory, folder);
public static string CurrentDirectory => BlogDirectory.Value;
public static class Blog
{
private static readonly IDictionary<string, string> Keywords =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "c#", "csharp" },
{ ".net", "dotnet" },
{ "asp.net", "aspnet" }
};
private static readonly string[] MarkdownExtensions = new []
{
".markdown",
".md"
};
private static Lazy<IReadOnlyList<Post>> posts =
new Lazy<IReadOnlyList<Post>>(() =>
{
var directory = GetDirectory("_posts");
var posts = Directory
.GetFiles(directory)
.Where(x =>
{
var ext = Path.GetExtension(x);
return MarkdownExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase);
})
.OrderByDescending(x => x)
.Select(x => new Post(x))
.ToList()
.AsReadOnly();
return posts;
});
public static IReadOnlyList<Post> Posts => posts.Value;
public static Post Latest =>
Posts.FirstOrDefault() ?? new Post("");
public static Post Nearest =>
Posts.Select(p => new {
ticks = Math.Abs((p.Date - DateTime.Now).Ticks),
post = p
})
.OrderBy(p => p.ticks)
.Select(p => p.post)
.FirstOrDefault() ?? new Post("");
private static DateTime Next(DateTime from, DayOfWeek dayOfTheWeek)
{
var date = from.AddDays(1);
var days = ((int) dayOfTheWeek - (int) date.DayOfWeek + 7) % 7;
return date.AddDays(days);
}
public static DateTime Next()
{
// We want the day after the latest post
// to exclude it from the process
var date = Latest?.Date ?? DateTime.Now;
// get next Tuesday and next Thursday
var dates = new []
{
Next(date, DayOfWeek.Tuesday),
Next(date, DayOfWeek.Thursday)
};
return dates.Min();
}
public static async Task<Post> CreateFile(string title, DateTime date, string[] tags = null)
{
var contents = new StringBuilder();
contents.AppendLine("---");
contents.AppendLine("layout: post");
contents.AppendLine($"title: \"{title}\"");
contents.AppendLine($"categories: [{string.Join(", ", tags ?? new string[0])}]");
contents.AppendLine($"date:{date:yyyy-MM-dd HH:mm:ss zz00}");
contents.AppendLine("---");
// slug clean up for pesky words
var slug = title;
foreach (var keyword in Keywords) {
slug = slug.Replace(keyword.Key, keyword.Value);
}
slug = slug.ToUrlSlug();
var filename = $"{date:yyyy-MM-dd}-{slug}.md";
var path = Path.Combine(CurrentDirectory, "_posts", filename);
await File.WriteAllTextAsync(path, contents.ToString());
return new Post(path);
}
}
}
public class Post
{
public Post(string fullPath)
{
FullPath = fullPath;
if (!string.IsNullOrWhiteSpace(fullPath))
{
Filename = Path.GetFileName(FullPath);
Name = Path.GetFileNameWithoutExtension(Filename[11..]);
Date = DateTime.Parse(Filename[..10]);
}
}
public string FullPath { get; }
public string Filename { get; }
public string Name { get; }
public DateTime Date { get; }
}
public static class UrlSlugger
{
// white space, em-dash, en-dash, underscore
static readonly Regex WordDelimiters = new Regex(@"[\s—–_]", RegexOptions.Compiled);
// characters that are not valid
static readonly Regex InvalidChars = new Regex(@"[^a-z0-9\-]", RegexOptions.Compiled);
// multiple hyphens
static readonly Regex MultipleHyphens = new Regex(@"-{2,}", RegexOptions.Compiled);
public static string ToUrlSlug(this string value)
{
// convert to lower case
value = value.ToLowerInvariant();
// remove diacritics (accents)
value = RemoveDiacritics(value);
// ensure all word delimiters are hyphens
value = WordDelimiters.Replace(value, "-");
// strip out invalid characters
value = InvalidChars.Replace(value, "");
// replace multiple hyphens (-) with a single hyphen
value = MultipleHyphens.Replace(value, "-");
// trim hyphens (-) from ends
return value.Trim('-');
}
/// See: http://www.siao2.com/2007/05/14/2629747.aspx
private static string RemoveDiacritics(string stIn)
{
string stFormD = stIn.Normalize(NormalizationForm.FormD);
StringBuilder sb = new StringBuilder();
for (int ich = 0; ich < stFormD.Length; ich++)
{
UnicodeCategory uc = CharUnicodeInfo.GetUnicodeCategory(stFormD[ich]);
if (uc != UnicodeCategory.NonSpacingMark)
{
sb.Append(stFormD[ich]);
}
}
return (sb.ToString().Normalize(NormalizationForm.FormC));
}
}
}
Info Command
The info
command helps me understand my current progress and helping to plan my next posts. I use this command more than I thought I would, as it helps me feel calm and less pressured when I see the backlog of posts that I have created. Here is the resulting output.
The command uses Oakton and Spectre.Console and we can execute it with the following command.
> dotnet run info
Here is the code to make the info command work.
using System;
using System.Linq;
using Oakton;
using Spectre.Console;
namespace blog.commands
{
public class InfoCommand
: OaktonCommand<InfoCommand.Options>
{
public class Options { }
public override bool Execute(Options input)
{
var now = DateTime.Now;
var latest = Settings.Blog.Latest;
var nearest = Settings.Blog.Nearest;
var recent = Settings.Blog.Posts.Skip(1).Take(5).ToList();
var next = Settings.Blog.Next();
var daysLeft = Math.Max(0, (int) (latest.Date - now).TotalDays);
string recentFormat(Post post) =>
post == null
? "[purple](n/a)[/]"
: $"[hotpink]‣[/] [purple]{post?.Name}[/] [fuchsia]({post?.Date:d})[/]";
var grid = new Grid { Expand = false }
.AddColumns(
new GridColumn().LeftAligned(),
new GridColumn().LeftAligned(),
new GridColumn(),
new GridColumn { NoWrap = true }.LeftAligned()
)
.AddRow("🌝", "[pink3]Today[/]", ":", $"[purple]{now:d}[/]")
.AddRow("📝", "[pink3]Latest post[/]", ":", $"[purple]{latest.Name}[/] [fuchsia]({latest.Date:d})[/]")
.AddRow("🔥", "[pink3]Nearest post[/]", ":", $"[purple]{nearest.Name}[/] [fuchsia]({nearest.Date:d})[/]")
.AddRow("🚀", "[pink3]Next post date[/]", ":", $"[purple]{next:MM/dd/yyyy ddddd}[/]")
.AddRow("🤔", "[pink3]# of days away[/]", ":", $"[purple]{daysLeft}[/]")
.AddRow("🧮", "[pink3]# of posts[/]", ":", $"[purple]{Settings.Blog.Posts.Count}[/]")
.AddRow("🦄", "[pink3]Latest posts[/]", ":", recentFormat(recent.FirstOrDefault()));
foreach (var post in recent.Skip(1)) {
grid.AddRow("", "", "", recentFormat(post));
}
var output = new Panel(grid)
.SetHeader(
" Blog Information ",
Style
.WithBackground(Color.MediumPurple4)
.WithForeground(Color.NavajoWhite1)
.WithDecoration(Decoration.Italic)
,
Justify.Center
)
.SetBorderColor(Color.Pink3)
.SetPadding(1, 1, 1, 1)
.RoundedBorder();
AnsiConsole.WriteLine();
AnsiConsole.Render(output);
return true;
}
}
}
New Post Command
As mentioned earlier in the post, my writing schedule dictates that I publish a new post on Tuesday
and Thursday
. Instead of sitting down and looking at a calendar, we can automate that by using the Blog.Next
method. Here is my command for creating a new post within the schedule.
> dotnet run new "This is a new post" --tags asp.net
If I need to get my thoughts out immediately, I can use the now
flag.
> dotnet run new "This is a new post" -n
I can also start my favorite editor.
> dotnet run new "this is a post" -e
Let’s look at the code for the command.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Oakton;
using SimpleExec;
using Spectre.Console;
namespace blog.commands
{
public class NewCommand
: OaktonAsyncCommand<NewCommand.Options>
{
public class Options
{
public const string DefaultEditor = "rider";
[Description("Name of the post, will also be turned into slug for the url.")]
public string Title { get; set; }
[FlagAlias("now", 'n')]
[Description("Create a post based on today's date", Name = "now")]
public bool NowFlag { get; set; }
[FlagAlias("tags", 't')]
[Description("Tags to add to the newly created post.", Name = "tags")]
public List<string> TagsFlag { get; set; }
[FlagAlias("edit", 'e')]
[Description("Launch the editor to start writing", Name = "edit")]
public bool EditFlag { get; set; }
[FlagAlias("editor", longAliasOnly: true)]
[Description("The editor to launch. Rider by default.", Name = "edit")]
public string EditorFlag { get; set; }
}
public override async Task<bool> Execute(Options input)
{
var date = input.NowFlag ? DateTime.Now : Settings.Blog.Next();
date = new[] {DateTime.Now, date }.Max();
input.EditorFlag ??= Options.DefaultEditor;
AnsiConsole.MarkupLine($"‣ [purple]Creating post:[/] \"{input.Title}\"");
var post =
await Settings.Blog.CreateFile(input.Title, date, input.TagsFlag?.ToArray());
AnsiConsole.MarkupLine($"‣ [purple]date:[/] {post.Date:MM/dd/yyyy dddd}");
AnsiConsole.MarkupLine($"‣ [purple]post:[/] [link={post.FullPath}]{post.FullPath}[/]");
if (input.EditFlag) {
AnsiConsole.MarkupLine($"‣ [purple]starting editor:[/] ({input.EditorFlag})");
await Command.RunAsync(input.EditorFlag, $"{Settings.CurrentDirectory}", noEcho:true);
}
return true;
}
}
}
I cannot overstate how awesome it is for this command to do the date mathematics for me. When I want to write or schedule a post, it just works!
Server Command
Jekyll has a few flags we need to pass in to make local rendering of future posts possible. I encapsulated that logic into the ServerCommand
.
using System.Threading.Tasks;
using Oakton;
using SimpleExec;
namespace blog.commands
{
public class ServerCommand
: OaktonAsyncCommand<ServerCommand.Options>
{
public class Options
{
}
public override async Task<bool> Execute(Options input)
{
// allow to see future posts
await Command.RunAsync(
"bundle",
"exec jekyll serve --host=localhost --drafts --future --watch --livereload",
Settings.CurrentDirectory,
configureEnvironment: env => {
env.Add("JEKYLL_ENV", "development");
}
);
return true;
}
}
}
Conclusion
There you have it! By leaning on Jekyll and .NET, you can create your writing Frankenblog just like me. By depending on .NET OSS, I can automate tedious actions like scheduling and creating files. If you use my workflow, please let me know what things you add to your workflow and find it helpful for others.
Remember, you can fork this starter template from this GitHub repository called Frankenblog.
Please leave a comment below on your thoughts.
Also checkout out some of my previous posts about Oakton: