Introducing Razor SSG
Razor SSG is a Razor Pages powered Markdown alternative to Ruby's Jekyll & Next.js that's ideal for generating static websites & blogs using C#, Razor Pages & Markdown.
GitHub Codespaces Friendly​
In addition to having a pure Razor + .NET solution to create fast, CDN-hostable static websites, it also aims to provide a great experience from GitHub Codespaces, where you can create, modify, preview & check-in changes before the included GitHub Actions auto deploy changes to its GitHub Pages CDN - all from your iPad!
To see this in action, we walk through the entire workflow of creating, updating and adding features to a custom Razor SSG website from just a browser using Codespaces, that auto publishes changes to your GitHub Repo's gh-pages branch where it's hosted for free on GitHub Pages CDN:
Enhance with simple, modern JavaScript​
For enhanced interactivity, static markdown content can be progressively enhanced with Vue 3 components, as done in this example that embed's the GettingStarted.mjs Vue Component to create new Razor SSG App's below with:
<getting-started template="razor-ssg"></getting-started>
Although with full control over the websites _Layout.cshtml
, you're free to use any preferred JS Module or Web Component you prefer.
Razor Pages​
Your website can be built using either Markdown .md
or Razor .cshtml
pages, although it's generally recommended to
use Markdown to capture the static content for your website for improved productivity and ease of maintenance.
Content in Markdown, Functionality in Razor Pages​
The basic premise behind most built-in features is to capture static content in markdown using a combination of folder structure & file name conventions in addition to each markdown page's frontmatter & content. This information is then used to power each feature using Razor pages for precise layout and functionality.
The template includes the source code for each website feature, enabling full customization that also serves as good examples for how to implement your own custom markdown-powered website features.
Markdown Feature Structure​
All markdown features are effectively implemented in the same way, starting with a _folder for maintaining its static markdown content, a .cs class to load the markdown and a .cshtml Razor Page to render it:
Location | Description |
---|---|
/_{Feature} |
Maintains the static markdown for the feature |
Markdown.{Feature}.cs |
Functionality to read the feature's markdown into logical collections |
{Feature}.cshtml |
Functionality to Render the feature |
Configure.Ssg.cs | Initializes and registers the feature with ASP .NET's IOC |
Lets see what this looks like in practice by walking through the "Pages" feature:
Pages Feature​
The pages feature simply makes all pages in the _pages folder, available from /{filename}
.
Where the included pages:
/_pages​
- privacy.md
- speaking.md
- uses.md
Are made available from:
Loading Pages Markdown​
The code that loads the Pages feature markdown content is in Markdown.Pages.cs:
public class MarkdownPages : MarkdownPagesBase<MarkdownFileInfo>
{
public MarkdownPages(ILogger<MarkdownPages> log, IWebHostEnvironment env)
: base(log,env) {}
List<MarkdownFileInfo> Pages { get; set; } = new();
public List<MarkdownFileInfo> VisiblePages => Pages.Where(IsVisible).ToList();
public MarkdownFileInfo? GetBySlug(string slug) =>
Fresh(VisiblePages.FirstOrDefault(x => x.Slug == slug));
public void LoadFrom(string fromDirectory)
{
Pages.Clear();
var fs = AssertVirtualFiles();
var files = fs.GetDirectory(fromDirectory).GetAllFiles().ToList();
var log = LogManager.GetLogger(GetType());
log.InfoFormat("Found {0} pages", files.Count);
var pipeline = CreatePipeline();
foreach (var file in files)
{
var doc = Load(file.VirtualPath, pipeline);
if (doc == null)
continue;
Pages.Add(doc);
}
}
}
Which ultimately just loads Markdown files using the configured Markdig pipeline in its Pages
collection which is made available via its VisiblePages
property which returns all documents in development whilst hiding
Draft and content published at a Future Date from production builds.
Rendering Markdown Pages​
The pages are then rendered in Page.cshtml Razor Page
that's available from /{slug}
@page "/{slug}"
@model MyApp.Page
@inject MarkdownPages Markdown
@implements IRenderStatic<MyApp.Page>
@functions {
public List<Page> GetStaticProps(RenderContext ctx)
{
var markdown = ctx.Resolve<MarkdownPages>();
return markdown.VisiblePages.Map(page => new Page { Slug = page.Slug! });
}
}
@{
var doc = Markdown.GetBySlug(Model.Slug);
if (doc.Layout != null)
Layout = doc.Layout == "none"
? null
: doc.Layout;
ViewData["Title"] = doc.Title;
}
<link rel="stylesheet" href="css/typography.css">
<section class="flex-col md:flex-row flex justify-center mt-16 mb-16 md:mb-12">
<h1 class="text-4xl tracking-tight font-extrabold text-gray-900">
@doc.Title
</h1>
</section>
<div class="mx-auto">
<div class="mx-auto prose lg:prose-xl mb-24">
@Html.Raw(doc.Preview)
</div>
</div>
@await Html.PartialAsync("HighlightIncludes")
<script>hljs.highlightAll()</script>
Which uses a custom layout if one is defined in its frontmatter which speaking.md utilizes in its layout frontmatter:
---
title: Speaking
layout: _LayoutContent
---
To render the page using _LayoutContent.cshtml visible by the background backdrop in its /speaking page.
What's New Feature​
The /whatsnew page is an example of creating a custom Markdown feature to implement a portfolio or a product releases page where a new folder is created per release, containing both release date and release or project name, with all features in that release maintained markdown content sorted in alphabetical order:
/_whatsnew​
- /2023-03-08_Animaginary
- feature1.md
- /2023-03-18_OpenShuttle
- feature1.md
- /2023-03-28_Planetaria
- feature1.md
What's New follows the same structure as Pages feature which is loaded in:
and rendered in:
Blog Feature​
The blog maintains its markdown posts in a flat folder which each Markdown post containing its publish date and URL slug it should be published under:
/_posts​
- ...
- 2023-01-21_start.md
- 2023-03-21_javascript.md
- 2023-03-28_razor-ssg.md
As the Blog has more features it requires a larger Markdown.Blog.cs to load its Markdown posts that is rendered in several different Razor Pages for each of its Views:
Page | Description | Example |
---|---|---|
Blog.cshtml | Main Blog layout | /blog |
Posts/Index.cshtml | Navigable Archive grid of Posts | /posts |
Posts/Post.cshtml | Individual Blog Post (like this!) | /posts/razor-ssg |
Author.cshtml | Display Posts by Author | /posts/author/lucy-bates |
Tagged.cshtml | Display Posts by Tag | /posts/tagged/markdown |
Year.cshtml | Display Posts by Year | /posts/year/2023 |
General Features​
Most unique markdown features are captured in their Markdown's frontmatter metadata, but in general these features are broadly available for all features:
- Live Reload - Latest Markdown content is displayed during Development
- Custom Layouts - Render post in custom Razor Layout with
layout: _LayoutAlt
- Drafts - Prevent posts being worked on from being published with
draft: true
- Future Dates - Posts with a future date wont be published until that date
Initializing and Loading Markdown Features​
All markdown features are initialized in the same way in Configure.Ssg.cs where they're registered in ASP.NET Core's IOC and initialized after the App's plugins are loaded by injecting with the App's Virtual Files provider before using it to read from the directory where the markdown content for each feature is maintained:
public class ConfigureSsg : IHostingStartup
{
public void Configure(IWebHostBuilder builder) => builder
.ConfigureServices(services =>
{
services.AddSingleton<RazorPagesEngine>();
services.AddSingleton<MarkdownPages>();
services.AddSingleton<MarkdownWhatsNew>();
services.AddSingleton<MarkdownBlog>();
})
.ConfigureAppHost(afterPluginsLoaded: appHost => {
var pages = appHost.Resolve<MarkdownPages>();
var whatsNew = appHost.Resolve<MarkdownWhatsNew>();
var blogPosts = appHost.Resolve<MarkdownBlog>();
var features = new IMarkdownPages[] { pages, whatsNew, blogPosts };
features.Each(x => x.VirtualFiles = appHost.VirtualFiles);
// Custom initialization
blogPosts.Authors = Authors;
// Load feature markdown content
pages.LoadFrom("_pages");
whatsNew.LoadFrom("_whatsnew");
blogPosts.LoadFrom("_posts");
});
});
//...
}
These dependencies are then injected in the feature's Razor Pages to query and render the loaded markdown content.
Custom Frontmatter​
You can extend the MarkdownFileInfo
type used to maintain the markdown content and metadata of each loaded Markdown file
by adding any additional metadata you want included as C# properties on:
// Add additional frontmatter info to include
public class MarkdownFileInfo : MarkdownFileBase
{
}
Any additional properties are automatically populated using ServiceStack's built-in Automapping which includes rich support for converting string frontmatter values into native .NET types.
Updating to latest versions​
You can easily update all the JavaScript dependencies used in postinstall.js by running:
node postinstall.js
This will also update the Markdown features *.cs
implementations which is delivered as source files instead of an external
NuGet package to enable full customization, easier debugging whilst supporting easy upgrades.
If you do customize any of the .cs
files, you'll want to exclude them from being updated by removing them from:
const hostFiles = [
'Markdown.Blog.cs',
'Markdown.Pages.cs',
'Markdown.WhatsNew.cs',
'MarkdownPagesBase.cs',
]
Markdown Tag Helper​
The included MarkdownTagHelper.cs can be used in hybrid Razor Pages like About.cshtml to render the /about page which requires the flexibility of Razor Pages with a static content component which you prefer to maintain inline with Markdown.
The <markdown />
tag helper renders plain HTML, which you can apply Tailwind's @typography
styles by including typography.css and annotating it with your preferred prose
variant, e.g:
<link rel="stylesheet" href="css/typography.css">
<markdown class="prose">
Markdown content...
</markdown>
Static Static Generation (SSG)​
All features up till now describes how this template implements a Markdown powered Razor Pages .NET application, where this template
differs in its published output, where instead of a .NET App deployed to a VM or App server it generates static *.html
files that's
bundled together with /wwwroot
static assets in the /dist
folder that can be previewed by launching a HTTP Server from that
folder with the built-in npm script:
npm run serve
To run npx http-server on http://localhost:8080
that you can open in a browser to preview the published version of your
site as it would be when hosted on a CDN.
Static Razor Pages​
The static generation functionality works by scanning all your Razor Pages and prerendering the pages with prerendering instructions.
Pages with Static Routes​
Pages with static routes can be marked to be prerendered by annotating it with the [RenderStatic]
attribute as done in
About.cshtml:
@page "/about"
@attribute [RenderStatic]
Which saves the pre-rendered page using the pages route with a .html suffix, e.g: /{@page route}.html
whilst pages with static
routes with a trailing /
are saved to /{@page route}/index.html
as done for
Posts/Index.cshtml:
@page "/posts/"
@attribute [RenderStatic]
Explicit generated paths​
To keep the generated pages in-sync with using the same routes as your Razor Pages in development it's recommended to use the implied rendered paths, but if preferred you can specify which path the page should be rendered to instead with:
@page "/posts/"
@attribute [RenderStatic("/posts/index.html")]
Pages with Dynamic Routes​
Prerendering dynamic pages follows Next.js getStaticProps
convention which you can implement using IRenderStatic<PageModel>
by returning a Page Model for each page that should be generated
as done in Posts/Post.cshtml and
Page.cshtml:
@page "/{slug}"
@model MyApp.Page
@implements IRenderStatic<MyApp.Page>
@functions {
public List<Page> GetStaticProps(RenderContext ctx)
{
var markdown = ctx.Resolve<MarkdownPages>();
return markdown.VisiblePages.Map(page => new Page { Slug = page.Slug! });
}
}
...
In this case it returns a Page Model for every Visible markdown page in
/_pages that ends up rendering the following pages in /dist
:
/privacy.html
/speaking.html
/uses.html
Limitations​
The primary limitations for developing statically generated Apps is that a snapshot of entire App is generated at deployment, which prohibits being able to render different content per request, e.g. for Authenticated users which would require executing custom JavaScript after the page loads to dynamically alter the page's initial content.
Otherwise in practice you'll be able develop your Razor Pages utilizing Razor's full feature-set, the primary concessions stem from Pages being executed in a static context which prohibits pages from returning dynamic content per request, instead any "different views" should be maintained in separate pages.
No QueryString Params​
As the generated pages should adopt the same routes as your Razor Pages you'll need to avoid relying on ?QueryString params and instead capture all required parameters for a page in its @page route as done for:
@page "/posts/author/{slug}"
@model AuthorModel
@inject MarkdownBlog Blog
@implements IRenderStatic<AuthorModel>
@functions {
public List<AuthorModel> GetStaticProps(RenderContext ctx) => ctx.Resolve<MarkdownBlog>()
.AuthorSlugMap.Keys.Map(x => new AuthorModel { Slug = x });
}
...
Which lists all posts by an Author, e.g: /posts/author/lucy-bates, likewise required for:
@page "/posts/tagged/{slug}"
@model TaggedModel
@inject MarkdownBlog Blog
@implements IRenderStatic<TaggedModel>
@functions {
public List<TaggedModel> GetStaticProps(RenderContext ctx) => ctx.Resolve<MarkdownBlog>()
.TagSlugMap.Keys.Map(x => new TaggedModel { Slug = x });
}
...
Which lists all related posts with a specific tag, e.g: /posts/tagged/markdown, and for:
@page "/posts/year/{year}"
@model YearModel
@inject MarkdownBlog Blog
@implements IRenderStatic<YearModel>
@functions {
public List<YearModel> GetStaticProps(RenderContext ctx) => ctx.Resolve<MarkdownBlog>()
.VisiblePosts.Select(x => x.Date.GetValueOrDefault().Year)
.Distinct().Map(x => new YearModel { Year = x });
}
...
Which lists all posts published in a specific year, e.g: /posts/year/2023.
Conceivably these "different views" could've been implemented by the same page with different ?author
, ?tag
and ?year
QueryString params, but are instead extracted into different pages to support its statically generated *.html
outputs.
Prerendering Task​
The prerender AppTask that pre-renders the entire website is also registered in Configure.Ssg.cs:
.ConfigureAppHost(afterAppHostInit: appHost =>
{
// prerender with: `$ npm run prerender`
AppTasks.Register("prerender", args =>
{
var distDir = appHost.ContentRootDirectory.RealPath.CombineWith("dist");
if (Directory.Exists(distDir))
FileSystemVirtualFiles.DeleteDirectory(distDir);
FileSystemVirtualFiles.CopyAll(
new DirectoryInfo(appHost.ContentRootDirectory.RealPath.CombineWith("wwwroot")),
new DirectoryInfo(distDir));
var razorFiles = appHost.VirtualFiles.GetAllMatchingFiles("*.cshtml");
RazorSsg.PrerenderAsync(appHost, razorFiles, distDir).GetAwaiter().GetResult();
});
});
//...
Which we can see:
- Deletes
/dist
folder - Copies
/wwwroot
contents into/dist
- Passes all App's Razor
*.cshtml
files toRazorSsg
to do the pre-rendering
Where it processes all pages with [RenderStatic]
and IRenderStatic<PageModel>
prerendering instructions to the
specified /dist
folder.
Previewing prerendered site​
To preview your SSG website, run the prerendered task with:
npm run prerender
Which renders your site to /_dist
which you can run a HTTP Server from with:
npm run serve
That you can preview with your browser at http://localhost:8080
.
Publishing​
The included build.yml GitHub Action takes care of running the prerendered task and deploying it to your Repo's GitHub Pages where it will be available at:
https://$org_name.github.io/$repo/
Alternatively you can use a Custom domain for GitHub Pages by registering a CNAME DNS entry for your preferred Custom Domain, e.g:
Record | Type | Value | TTL |
---|---|---|---|
mydomain.org | CNAME | org_name.github.io | 3600 |
That you can either configure in your Repo settings
or if you prefer to maintain it with your code-base, save the domain name to /wwwroot/CNAME
, e.g:
www.mydomain.org
Benefits after migrating from Jekyll​
Whilst still only at v1 release, we found it already had a number of advantages over the existing Jekyll static website:
- Faster live reloads
- C#/Razor more type-save & productive than Ruby/Liquid
- Greater flexibility in implementing new features
- Better IDE support (from Rider)
- Ability to reuse our .NET libraries
- Better development experience
The last point ultimately prompted seeking an alternative solution as previously Jekyll was used from Windows/WSL which was awkward to manage from a different filesystem with Jekyll upgrades breaking RubyMine support forcing the use of text editors to maintain its code-base and content.
Used by the new servicestack.net​
Deterred by the growing complexity of current SSG solutions, we decided to create a new solution using C#/Razor (our preferred technology for generating server HTML) with a clean implementation that allowed full control with an npm dependency-free solution letting us adopt our preferred approach to Simple, Modern JavaScript without any build-tooling or SPA complexity.
We're happy with the results of https://servicestack.net new Razor SSG website:
A clean, crisp code-base utilizing simple JS Module Vue 3 components, the source code of which is publicly maintained at:
Which serves as a good example at how well this template scales for larger websites.
Markdown Videos Feature​
It only needed one new Markdown feature to display our growing video library:
- /_videos - Directory of Markdown Video collections
- Markdown.Videos.cs - Loading Video feature markdown content
- Shared/VideoGroup.cshtml - Razor Page for displaying Video Collection
Which you're free to reuse in your own websites needing a similar feature.
Feedback & Feature Requests Welcome​
In future we'll look at expanding this template with generic Markdown features suitable for websites, blogs & portfolios, or maintain a shared community collection if there ends up being community contributions of Razor SSG & Markdown features.
In the meantime, we welcome any feedback or new feature requests at: