Faking Time Magic with Nitride

It's been a while since I've talked about MfGames.Nitride and I thought I would do a short post about working with time-sensitive posts. In most cases, this is dating blob posts but it can also be how I dole out weekly chapters while building up a buffer while I enjoy some down time.

NodaTime

All the time elements in Nitride use NodaTime instead of the base class library's DateTime. Part of this is because it predates the advent of LocalDate but also because NodaTime has good abstractions around providing the time for reliable manipulations of time, an obsession with correctness, and also a deep understanding of the complexities of temporal elements.

None of these really matter, but I like the library and I felt it was a solid base. This is also one reason why Nitride is opinionated.

Getting Started

To use the temporal library, you need to include the NuGet package into your project and then either inject the module into the class or use the extension method.

NitrideBuilder builder = new NitrideBuilder(args)
    .UseTemporal(
        config =>
        {
            config
                .WithDateTimeZone("America/Chicago")
                .WithDateOptionCommandLineOption();
        });

The configuration is optional, but this shows the two most common options. The first sets the time zone for all the local dates to handle when your site generates even if your server or build machine is UTC or in a different time zone.

The other adds a --date YYYY-MM-DD option to the command line to let you choose the “now” that the site is running, such as to preview next week's post or see how everything will turn out. If the --date is not provided or the command line option isn't used, then “now” will be now. Well, then, but it was now then, but if you run it now, then it will now. Well, then. (Spaceballs reference.)

dotnet run -- build --date 2023-01-25

The Basics

Even with the module, nothing more is going to happen than additional logging line in the output. Because this is an Entity-Component-System, adding time to an Entity is as simple as setting the instant.

Instant instant;

var entity = new Entity().Set(instant);

Of course, this only applies to a single entry, so there are two operations included that will mass assign Instant components to entities. The first is SetInstantFromComponent. This gets a LocalDate, DateTime, DateTimeOffset, or Instant from the entity (Model.Date in this example) and returns it. If this returns a null, then no instant will be assigned.

public class Model
{
    public string Access { get; set; } = "public";
    public DateTime? Date { get; set; }
}

public Pipeline : PipelineBase
{
    private SetInstantFromComponent<PageModel> op;

    public Pipeline(
        SetInstantFromComponent<PageModel> op)
    {
        this.op = op
        .WithGetDateTimeObject(entity => entity.Get<Model>().Date);
    }

    public override IAsyncEnumerable<Entity> RunAsync(
        IEnumerable<Entity> entities,
        CancellationToken cancellationToken = default)
    {
        return entities
            .Run(op)
            .ToList()
            .ToAsyncEnumerable();
    }
}

As a basic functionality, it is pretty simple. However, the most common way I need to assign an Instant are my blog posts which all have the following pattern:

  • ./posts/2023-01-21/faking-time-magic-with-nitride/index.md

To handle this common condition, there is a second operation: SetInstantFromPath.

SetInstantFromPath op;

return entities
    .Run(op)
    .ToList()
    .ToAsyncEnumerable();

This one doesn't require any customization to it because it uses a regular expression with named groups to calculate the three components. This is the same as setting the regular expression:

return entities
    .Run(op.WithPathRegex(@"(?<year>\d{4})[/-](?<month>\d{2})[/-](?<day>\d{2})"))
    .ToList()
    .ToAsyncEnumerable();

Obviously the default is ISO date time, but I'm not going to support anything besides that in the defaults since you can writ a custom regular expression with named groups to handle any arbitrary format. The default also handles 2028/01-21-faking-time-magic-with-nitride.md as well as my normal format. In the end, the Entity has an Instant component.

Using Instants

Of course, setting an Instant requires it to be used somewhere. Since it is a component, it is relatively easy to get the latest X posts from a list of entities.

var latest = entities
    .WhereEntity<Instant>()
    .OrderByDescening(entity => entity.Get<Instant>())
    .Take(x)
    .ToList();

This is used to create feeds, but that is a topic for later since generating Atom feeds needs some more attention before I'm comfortable with it.

The instant can also be used to remove future instants via the creatively named FilterOutFutureInstant which has no configurations thank to the use of the --date parameter and TimeService in its dependency-injected constructor.

FilterOutFutureInstant op;

return entities
    .Run(op)
    .ToList()
    .ToAsyncEnumerable();

In most cases, I gather up all the pages, generate the project pages so I can show future chapters but grayed out, then remove the future ones before producing the HTML so no one can “cheat” by just adding one to the page.

Schedules

The above operations and concepts pretty much got me through generating d.moonfire.us. On the other hand, fedran.com needed something more since I don't date my chapters. Instead, they are things like fedran.com/sand-and-blood/chapter-01/ and they need to be sent out every week until I run out of buffer or fail to keep up.

To support that, I added a second NuGet package, MfGames.Temporal.Schedules which contains more complex operations that adjust or change an entity's attributes based on the current date. In both cases, they are driven off a “path” which may be any arbitrary component but default to the entity's Zio.Upath component. They allow me to set up a schedule in a separate YAML file (could be JSON, could be on the page) and then they change.

PeriodicPathRegexSchedule

The first is PeriodicPathRegexSchedule which is the initial evolution of writing a schedule for static sites. It uses a regular expression to capture the numeric part of a path (12 from chapter-12) and figure out how long to show it. If we use the Model.Access above to control access, we can easily make chapters available for subscribers and then spread them out a week at a time starting at the beginning of the year.

schedules:
  # Patron and Ko-Fi subscribers get it all at once
  - pathRegex: chapters/chapter-(\d+)
    scheduleStart: 2000-01-01
    schedulePeriod: instant
    access: subscribers
  - pathRegex: chapters/chapter-(\d+)
    scheduleStart: 2023-01-01
    schedulePeriod: 1 week
    access: public

Depending on the “now” when the site is run, all the chapters are available to patrons but they will be made public at the rate of one per week starting with the first chapter on January 1st. Of course, with regular expressions, I could easily stop posting chapters for a while by splitting the regular expression in two to cover two ranges. Or I can have two schedules that have a tier-1 and a tier-2 release with a public release a week after tier-2 gets it.

schedules:
  - pathRegex: chapters/chapter-(\d+)
    scheduleStart: 2000-01-01
    schedulePeriod: tier-1
    access: subscribers
  - pathRegex: chapters/chapter-(\d+)
    scheduleStart: 2023-01-01
    schedulePeriod: 1 week
    access: tier-2
  - pathRegex: chapters/chapter-(\d+)
    scheduleStart: 2023-01-08
    schedulePeriod: 1 week
    access: public

This can also get complicated when I had to take breaks from chapters:

schedules:
  # Patron and Ko-Fi subscribers get it all at once
  - pathRegex: chapters/chapter-([0-1]\d|2[0-4])
    scheduleStart: 2000-01-01
    schedulePeriod: 2 weeks
    access: public
  - pathRegex: chapters/chapter-(2[5-9]|[3-9]\d)
    scheduleStart: 2023-01-01
    schedulePeriod: 2 weeks
    access: public

Obviously, implementing this is more complicated then setting the instant from the path.

public class PageSchedule : NumericalPathSchedule
{
    public Access { get; set; }

    protected override Entity Apply(Entity entity, int number, Instant instant)
    {
        var model = entity.Get<Model>();
        model.Access = this.Access;
        return entity.Set(instant, model);
    }
}

List<PageSchedule> schedules;
Ienumerable<Entity> entities;

return applySchedules.WithSchedules(_ => schedules).Run(entities);

IndexedPathRegexSchedule

The second method for schedules is IndexedPathRegexSchedule. This is the most complicated to set up in C# code, but I find the easiest to comprehend in the YAML code. Instead of having a complex set of schedules and multiple regular expressions, it goes with a single regular expression to identify the numerical component and then has a dictionary of elements that say “from chapters X until the next, for chapters Y and later, do this, etc.”

schedules:
    pathRegex: chapters/chapter-(\d+)
    indexes:
        1:
            scheduleStart: 2025-01-01
            schedulePeriod: instant # means all the chapters 1-10 at once
            access: subscribers
        10:
            scheduleStart: 2030-01-01 # chapter 10 on 2030-01-01, chapter 11 on 2023-01-08, etc.
            schedulePeriod: 1 week
            access: subscribers
        30:
            schedulePeriod: never # never going to see these

This one works easier for me, mainly because Fedran doesn't have subscriber tiers or complex logic but I do have frequent pauses while posting. Also, the schedule isn't an outer list (though it could be, ApplySchedules applies every schedule in order so you can have two of these without a problem).

public class Schedule : IndexedSchedule
{
    public string? Access { get; set; }

    protected override Entity Apply(
        Entity entity,
        int number,
        Instant instant)
    {
        TestModel model = entity.Get<TestModel>();
        model.Access = this.Access;
        return entity.SetAll(instant, model);
    }
}

IndexedPathRegexSchedule<Schedule> schedules;
Ienumerable<Entity> entities;

return applySchedules.WithSchedules(_ => new [] { schedules }).Run(entities);

Conclusion

As you may notice, these don't filter out or do anything other than set the Instant and maybe some other components of an Entity. This follows the Single Responsibility Principle (SRP) but also “do one thing well”. If you want to filter it out, use an operation for that. If you want to dynamically change the contents of a page based on the current day, that's a schedule.

This is also a starting point. Everything is fluid. I have thirteen websites I'm planning on converting over to Nitride over the next year or so. The fourth site is mfgames.com when I'll start documenting all of this and probably start pushing to see if others want to use it. Right now, the only documentation comes from https://src.mfgames.com/mfgames-cil/ in the examples, read me files, and tests.

Metadata

Categories:

Tags: