Liquid Pages

CI

Liquid pages is a library that uitlizes Fluid under the hood to create middleware that can be used with any web server to render liquid templates. It has a MVVM philosophy similar to Razor Pages (hence the name Liquid Pages).

Setup

Start by adding the nuget package:

nuget add Kinetq.LiquidPages

Next add all of the Liquid Pages services via this helper method:

services.AddLiquidPages()

Next you'll add middleware, which could be different based on the web server you are using. Currently there is only one build in middleware for EmbedIO (although building your own is very simple and detailed below).

Startup

For Liquid Pages to properly function Routes, Filters and Types need to be registered before attempting to render a template. In order to do that there are two interfaces you'll need to inject:

public class WebsiteStartup : IStartup
{
    private readonly ILiquidRegisteredTypesManager _liquidRegisteredTypesManager;
    private readonly ILiquidStartup _liquidStartup;

    public WebsiteStartup(
        ILiquidRegisteredTypesManager liquidRegisteredTypesManager,
        ILiquidStartup liquidStartup)
    {
        _liquidRegisteredTypesManager = liquidRegisteredTypesManager;
        _liquidStartup = liquidStartup;
    }

    public async Task Execute()
    {

        await _liquidStartup.RegisterRoutes();
        await _liquidStartup.RegisterFilters();

        _liquidRegisteredTypesManager.RegisterType<NavItemViewModel>();
        _liquidRegisteredTypesManager.RegisterType<NavViewModel>();
        _liquidRegisteredTypesManager.RegisterType<LinkViewModel>();
        _liquidRegisteredTypesManager.RegisterType<TagViewModel>();
        _liquidRegisteredTypesManager.RegisterType<CategoryItemViewModel>();
        _liquidRegisteredTypesManager.RegisterType<BlogPostViewModel>();
        _liquidRegisteredTypesManager.RegisterType<CategoryViewModel>();
        _liquidRegisteredTypesManager.RegisterType<WebPageViewModel>();
        _liquidRegisteredTypesManager.RegisterType<ContactViewModel>();
        _liquidRegisteredTypesManager.RegisterType<LegalPageViewModel>();
        _liquidRegisteredTypesManager.RegisterType<ArticleItemViewModel>();
        _liquidRegisteredTypesManager.RegisterType<ArticlesViewModel>();
        _liquidRegisteredTypesManager.RegisterType<SubscriptionsViewModel>();
        _liquidRegisteredTypesManager.RegisterType<SearchViewModel>();
    }
}

Middleware

EmbedIO

nuget add Kinetq.LiquidPages.EmbedIO
var liquidWebModule = new LiquidWebModule("/")
{
    LiquidResponseMiddleware = _liquidResponseMiddleware,
    ExcludedPaths = new Regex[]
    {
        new Regex("^/api/.*"),
        new Regex("^/static/.*")
    }
};

webServer.WithModule(liquidWebModule);

Custom Middleware

Otherwise if there is no existing middleware for your webserver, the implementation would still be simple and look something like this:

 try
        {
            var liquidRequest = new LiquidRequestModel()
            {
                Route = request.Url.AbsolutePath,
                QueryParams = request.Url.Query.GetQueryParams(),
                Headers = request.Headers
            };

            if (request.HasEntityBody)
            {
                using var reader = new StreamReader(request.InputStream, Encoding.UTF8);
                liquidRequest.Body = await reader.ReadToEndAsync();
            }

            var responseModel =
                await LiquidResponseMiddleware.HandleRequestAsync(liquidRequest);

            response.ContentLength64 = responseModel.Content.Length;
            response.ContentType = responseModel.ContentType;
            response.StatusCode = responseModel.StatusCode;

            await response.OutputStream.WriteAsync(responseModel.Content);
        }
        catch (Exception ex)
        {
            response.StatusCode = 500;
            byte[] errorBuffer = Encoding.UTF8.GetBytes($"Internal Server Error: {ex.Message}");
            response.ContentLength64 = errorBuffer.Length;
            response.ContentType = "text/html";
            await response.OutputStream.WriteAsync(errorBuffer);
        }
        finally
        {
            response.Close();
        }
    }

Decentralized Templates

Liquid Pages embraces modularity by allowing templates to be defined in any C# project using the .NET IFileProvider abstraction. Each route can be backed by its own file provider, enabling a clean separation of concerns—whether you centralize all templates or distribute them across modules.

  • The library leverages the IFileProvider interface, making it possible to source templates from physical files, embedded resources, or even cloud storage.

  • Every LiquidRoute must specify a FileProvider that points to the directory containing its Liquid templates and associated assets (e.g., CSS, JavaScript, images).

  • When resolving a route, Liquid Pages looks for partial templates and static files exclusively within that route’s FileProvider, keeping each route self‑contained.

  • You can either configure a single file provider for the whole application or adopt a truly modular approach with a separate provider per route.

  • The example below shows a custom ILiquidFileProvider that switches between physical files during development and embedded resources in production:

public class LiquidFileProvider : ILiquidFileProvider
{
    public IFileProvider GetFileProvider()
    {
#if DEBUG

        string workingDirectory = Directory.GetCurrentDirectory();
        string projectDirectory = Directory.GetParent(workingDirectory).Parent.Parent.FullName;
        IFileProvider fileProvider = new PhysicalFileProvider(Path.Combine(projectDirectory, "Liquid"));

#else
        IFileProvider fileProvider = new EmbeddedFileProvider(typeof(LiquidFileProvider).Assembly, "Kinetq.Website.Liquid");
#endif

        return fileProvider;
    }
}

Routing

Routing in Liquid Pages follows a familiar pattern inspired by Razor Pages. You define routes by implementing the ILiquidRoute interface, which returns a LiquidRoute object containing all the information needed to match an incoming request and produce a response.

  • Routes are classes that implement ILiquidRoute and provide a LiquidRoute via the GetRoute() method.

  • LiquidRoute requires three mandatory properties:

    • RoutePattern: a regular expression that tests the request path.

    • LiquidTemplatePath: the path to the Liquid template file inside the specified FileProvider.

    • FileProvider: supplies the template and any related assets.

  • An optional Execute delegate can be attached to run server‑side logic before rendering. It receives a LiquidRequestModel and returns a view model, which becomes available in the template as view_model.

  • The following example, taken from the Liquid Pages GitHub repository, demonstrates a route that matches /about-us, fetches data from an index, and returns a WebPageViewModel:

public class AboutUsRoute : ILiquidRoute
{
    private readonly IIndexProvider _indexProvider;
    private readonly SiteConfig _siteConfig;
    private readonly ILiquidFileProvider _liquidFileProvider;

    public AboutUsRoute(IIndexProvider indexProvider, SiteConfig siteConfig, ILiquidFileProvider liquidFileProvider)
    {
        _indexProvider = indexProvider;
        _siteConfig = siteConfig;
        _liquidFileProvider = liquidFileProvider;
    }

    public async Task<LiquidRoute> GetRoute()
    {
        return new LiquidRoute
        {
            RoutePattern = new Regex($"^/about-us$"),
            LiquidTemplatePath = "about.liquid",
            FileProvider = _liquidFileProvider.GetFileProvider(),
            Execute = (async model =>
            {
                var webPage = await _indexProvider.Search()
                    .Must(() => new TermQuery(new Term(nameof(WebPage.SafeName), model.Route.TrimStart('/'))))
                    .SingleResult<WebPage>();

                string seoDescription = webPage.Hit.SeoDescription;

                string seoTitle = !string.IsNullOrEmpty(webPage.Hit.SeoTitle)
                    ? webPage.Hit.SeoTitle
                    : webPage.Hit.Name;

                seoTitle = _siteConfig.SeoTitle + seoTitle;

                return new WebPageViewModel()
                {
                    Title = webPage.Hit.Name,
                    Body = webPage.Hit.Body,
                    SeoDescription = seoDescription,
                    SeoTitle = seoTitle
                };
            })
        };
    }
}

Filters

Custom Liquid filters are created in a similar fashion to routes: implement ILiquidFilter and return a LiquidFilter object that pairs a filter name with a delegate. This design keeps filter logic encapsulated and testable while allowing full dependency injection.

  • Filters implement the ILiquidFilter interface and define a GetFilter() method that returns a LiquidFilter.

  • The LiquidFilter contains a Name (the identifier used in templates) and a FilterDelegate—the actual function that performs the transformation.

  • The FilterDelegate comes from the Fluid library and must return a value derived from FluidValue (e.g., ArrayValueObjectValueStringValue).

  • Because filters are registered in the DI container, they can consume any service, such as a search index or database context.

  • The example below shows a filter that retrieves featured articles and returns them as an array of view models:

public class GetFeaturedArticlesFilter : ILiquidFilter
{
    private readonly IIndexProvider _indexProvider;

    public GetFeaturedArticlesFilter(IIndexProvider indexProvider)
    {
        _indexProvider = indexProvider;
    }

    public async Task<LiquidFilter> GetFilter()
    {
        return new LiquidFilter()
        {
            Name = "get_featured_articles",
            FilterDelegate = GetFeaturedArticles
        };
    }

    private async ValueTask<FluidValue> GetFeaturedArticles(FluidValue input, FilterArguments arguments, TemplateContext context)
    {
        string filterInput = input.ToStringValue();

        var blogPosts =
            (await _indexProvider.Search()
                .Must(() => new TermQuery(new Term(nameof(BlogPost.IsPublished), Boolean.TrueString)))
                .Sort(() => new SortField(nameof(BlogPost.PublishedDate), SortFieldType.INT64, true))
                .Paged(1, 4)
                .ListResult<BlogPost>()).Hits
            .Select(x => x.Hit)
            .ToList();

        var featuredBlogPosts = new List<FluidValue>();
        foreach (var blogPost in blogPosts)
        {
            var blogPostViewModel = new LinkViewModel()
            {
                Title = blogPost.Name,
                Url = $"{blogPost.Path}"
            };

            featuredBlogPosts.Add(new ObjectValue(blogPostViewModel));
        }

        return new ArrayValue(featuredBlogPosts);
    }
}

Error Routes

Liquid Pages makes it easy to serve custom error pages by introducing the ILiquidErrorRoute interface. This works just like a normal route but is associated with a specific HTTP status code, allowing you to design branded 404, 500, or other error pages.

  • Error routes implement ILiquidErrorRoute, which extends the route concept with a StatusCode property.

  • The GetRoute() method returns a LiquidRoute—typically containing a FileProvider and a LiquidTemplatePath—that will be used when an error with that status code occurs.

  • The StatusCode property tells the middleware which HTTP error this route handles (e.g., 404 for Not Found).

  • You can omit the Execute delegate if the error page does not require dynamic data.

  • The example below, taken from the Liquid Pages repository, demonstrates a simple 404 error route:

public class NotFoundRoute : ILiquidErrorRoute
{
    private readonly ILiquidFileProvider _liquidFileProvider;

    public NotFoundRoute(ILiquidFileProvider liquidFileProvider)
    {
        _liquidFileProvider = liquidFileProvider;
    }

    public async Task<LiquidRoute> GetRoute()
    {
        return new LiquidRoute
        {
            FileProvider = _liquidFileProvider.GetFileProvider(),
            LiquidTemplatePath = "status_codes/404.liquid"
        };
    }

    public int StatusCode => 404;
}

Liquid Response Middleware

The ILiquidResponseMiddleware is the engine that ties everything together. After injecting it into your application, you call HandleRequestAsync with a LiquidRequestModel that encapsulates the incoming request. The middleware then orchestrates route matching, data retrieval, template parsing, and response generation, returning a LiquidResponseModel ready to be written to the output stream.

  • ILiquidResponseMiddleware is the core service; its HandleRequestAsync method accepts a LiquidRequestModel containing the route, query parameters, body, and headers.

  • The middleware iterates through all registered routes, matching the request path against each RoutePattern (regex).

  • When a match is found, it optionally invokes the route’s Execute delegate to obtain a view model.

  • The middleware then parses the Liquid template using the Fluid engine, passing the view model (accessible as view_model) and any custom filters registered in the system.

  • If no route matches, it attempts to serve static files (e.g., CSS, images) from the default file provider.

  • If neither a route nor a static file is found, it falls back to any configured error routes (such as the 404 route described above) to produce an appropriate response.

  • The final output is a byte array with the correct MIME type and HTTP status code, packaged in a LiquidResponseModel.

  • This design makes the middleware adaptable to any web server, as demonstrated in the generic integration example at the beginning of this document. For a detailed look at the implementation, see the LiquidResponseMiddleware.cs file in the repository.