Class
Sealed
public sealed class ScribanTemplateEngine
Namespace: Moka.Docs.Rendering.Scriban
Renders pages using Scriban templates from the active theme.
Constructors
| Name | Description |
|---|---|
ScribanTemplateEngine(ScribanTemplateEngine> logger) |
Renders pages using Scriban templates from the active theme. |
ScribanTemplateEngine(ScribanTemplateEngine> logger)
ScribanTemplateEngine.ScribanTemplateEngine(ILogger<ScribanTemplateEngine> logger)
Renders pages using Scriban templates from the active theme.
Methods
| Name | Description |
|---|---|
RenderPage(DocPage page, ThemeRenderContext themeContext) |
Renders a page using the specified layout template. |
RenderPage(DocPage page, ThemeRenderContext themeContext)
string ScribanTemplateEngine.RenderPage(DocPage page, ThemeRenderContext themeContext)
Renders a page using the specified layout template.
Parameters
| Name | Type | Description |
|---|---|---|
page | DocPage | The page to render. |
themeContext | Moka.Docs.Rendering.Scriban.ThemeRenderContext | The theme rendering context with templates, config, nav, etc. |
Returns: The fully rendered HTML string.
View Source
/// <summary>
/// Renders pages using Scriban templates from the active theme.
/// </summary>
public sealed class ScribanTemplateEngine(ILogger<ScribanTemplateEngine> logger)
{
private readonly Dictionary<string, Template> _templateCache = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Renders a page using the specified layout template.
/// </summary>
/// <param name = "page">The page to render.</param>
/// <param name = "themeContext">The theme rendering context with templates, config, nav, etc.</param>
/// <returns>The fully rendered HTML string.</returns>
public string RenderPage(DocPage page, ThemeRenderContext themeContext)
{
string layoutName = page.FrontMatter.Layout;
string templateContent = themeContext.GetTemplate(layoutName) ?? themeContext.GetTemplate("default") ?? throw new InvalidOperationException($"Layout template '{layoutName}' not found in theme.");
Template template = GetOrParseTemplate(layoutName, templateContent);
ScriptObject scriptObject = BuildScriptObject(page, themeContext);
var context = new TemplateContext();
context.PushGlobal(scriptObject);
context.MemberRenamer = member => member.Name;
try
{
string html = template.Render(context);
return FixPreBlockIndentation(html);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to render template '{Layout}' for page '{Route}'", layoutName, page.Route);
return $"<!-- Template rendering error: {ex.Message} -->\n{page.Content.Html}";
}
}
private Template GetOrParseTemplate(string name, string content)
{
if (_templateCache.TryGetValue(name, out Template? cached))
{
return cached;
}
var template = Template.Parse(content);
if (template.HasErrors)
{
string errors = string.Join("; ", template.Messages.Select(m => m.Message));
logger.LogWarning("Template '{Name}' has errors: {Errors}", name, errors);
}
_templateCache[name] = template;
return template;
}
private static string PrefixRoute(string route, string basePath)
{
if (basePath == "/")
{
return route;
}
if (string.IsNullOrEmpty(route) || route == "/")
{
return basePath + "/";
}
return basePath + (route.StartsWith('/') ? route : "/" + route);
}
private static string RewriteContentLinks(string html, string basePath)
{
if (basePath == "/" || string.IsNullOrEmpty(html))
{
return html;
}
// Rewrite href="/..." and src="/..." in rendered markdown content
return Regex.Replace(html, """(href|src)="(/(?!/)(?![a-zA-Z]+:))""", $"""$1="{basePath}$2""");
}
/// <summary>
/// Builds the final URL for a brand asset (<c>site.logo_url</c> / <c>site.favicon_url</c>).
/// Absolute URLs (http/https/protocol-relative/data URIs) pass through unchanged so CDN-
/// hosted brand assets work without a base-path prefix. Root-relative publish URLs get
/// the build <see cref = "BuildConfig.BasePath"/> prepended so GitHub Pages project-page
/// deploys (<c>/Moka.Red/_site/</c>) resolve correctly.
/// </summary>
private static string ResolveBrandUrl(SiteAssetReference? asset, string basePath)
{
if (asset is null)
{
return "";
}
if (asset.IsAbsoluteUrl)
{
return asset.PublishUrl;
}
if (basePath == "/" || string.IsNullOrEmpty(basePath))
{
return asset.PublishUrl;
}
// basePath already has no trailing slash (SiteConfigReader normalizes it);
// PublishUrl always starts with a single leading slash. Concatenation yields
// /basepath/assets/logo.png cleanly.
return basePath + asset.PublishUrl;
}
private static ScriptObject BuildScriptObject(DocPage page, ThemeRenderContext ctx)
{
var so = new ScriptObject();
string bp = ctx.Config.Build.BasePath;
// Base path for templates and JS
so.SetValue("base_path", bp == "/" ? "" : bp, false);
#region Page Data
so.SetValue("page", new ScriptObject { { "title", page.FrontMatter.Title }, { "description", page.FrontMatter.Description }, { "content", RewriteContentLinks(page.Content.Html, bp) }, { "route", PrefixRoute(page.Route, bp) }, { "toc", BuildTocObject(page.TableOfContents) }, { "show_toc", page.FrontMatter.Toc && page.TableOfContents.Entries.Count > 0 && ctx.Config.Theme.Options.ShowTableOfContents }, { "tags", page.FrontMatter.Tags }, { "layout", page.FrontMatter.Layout }, { "source_path", page.SourcePath ?? "" }, { "last_modified", page.LastModified?.ToString("yyyy-MM-dd") ?? "" }, { "is_api", page.Origin == PageOrigin.ApiGenerated } }, false);
#endregion
#region Site Config
// Brand assets expose two script variables each:
// site.logo / site.favicon — the user's raw yaml value (for backward
// compatibility with any custom template that
// read the old string directly).
// site.logo_url / site.favicon_url — the final resolved URL the theme should emit.
// Absolute URLs pass through unchanged; relative
// paths get the BasePath prefix prepended.
// Templates in EmbeddedThemeProvider use the *_url variants so they "just work" for
// both GitHub Pages subpath deploys and CDN-hosted brand assets without any
// conditional logic in the scriban markup.
so.SetValue("site", new ScriptObject { { "title", ctx.Config.Site.Title }, { "description", ctx.Config.Site.Description }, { "url", ctx.Config.Site.Url }, { "copyright", ctx.Config.Site.Copyright ?? "" }, { "logo", ctx.Config.Site.Logo?.RawValue ?? "" }, { "favicon", ctx.Config.Site.Favicon?.RawValue ?? "" }, { "logo_url", ResolveBrandUrl(ctx.Config.Site.Logo, bp) }, { "favicon_url", ResolveBrandUrl(ctx.Config.Site.Favicon, bp) } }, false);
#endregion
#region Theme Options
so.SetValue("theme", new ScriptObject { { "primary_color", ctx.Config.Theme.Options.PrimaryColor }, { "accent_color", ctx.Config.Theme.Options.AccentColor }, { "code_theme", ctx.Config.Theme.Options.CodeTheme }, { "show_edit_link", ctx.Config.Theme.Options.ShowEditLink }, { "show_last_updated", ctx.Config.Theme.Options.ShowLastUpdated }, { "color_themes", ctx.Config.Theme.Options.ColorThemes }, { "code_theme_selector", ctx.Config.Theme.Options.CodeThemeSelector }, { "code_style", ctx.Config.Theme.Options.CodeStyle }, { "code_style_selector", ctx.Config.Theme.Options.CodeStyleSelector }, { "show_feedback", ctx.Config.Theme.Options.ShowFeedback }, { "show_dark_mode_toggle", ctx.Config.Theme.Options.ShowDarkModeToggle }, { "show_animations", ctx.Config.Theme.Options.ShowAnimations }, { "show_search", ctx.Config.Theme.Options.ShowSearch }, { "show_table_of_contents", ctx.Config.Theme.Options.ShowTableOfContents }, { "show_prev_next", ctx.Config.Theme.Options.ShowPrevNext }, { "show_breadcrumbs", ctx.Config.Theme.Options.ShowBreadcrumbs }, { "show_back_to_top", ctx.Config.Theme.Options.ShowBackToTop }, { "show_copy_button", ctx.Config.Theme.Options.ShowCopyButton }, { "show_line_numbers", ctx.Config.Theme.Options.ShowLineNumbers }, { "toc_depth", ctx.Config.Theme.Options.TocDepth }, { "show_version_selector", ctx.Config.Theme.Options.ShowVersionSelector }, { "show_built_with", ctx.Config.Theme.Options.ShowBuiltWith }, { "social_links", BuildSocialLinks(ctx.Config.Theme.Options.SocialLinks) }, { "default_color_theme", ctx.Config.Theme.Options.DefaultColorTheme } }, false);
#endregion
// MokaDocs version for footer branding
Version? asmVersion = typeof(ScribanTemplateEngine).Assembly.GetName().Version;
so.SetValue("mokadocs_version", asmVersion is not null ? $"{asmVersion.Major}.{asmVersion.Minor}.{asmVersion.Build}" : "1.0.0", false);
// Edit link
if (ctx.Config.Site.EditLink is { } el && ctx.Config.Theme.Options.ShowEditLink)
{
string editUrl = $"{el.Repo.TrimEnd('/')}/edit/{el.Branch}/{el.Path.TrimEnd('/')}/{page.SourcePath}";
so.SetValue("edit_url", editUrl, false);
}
#region Navigation and Breadcrumbs
var pageRoutes = new HashSet<string>(ctx.AllPages.Select(p => p.Route), StringComparer.OrdinalIgnoreCase);
so.SetValue("nav", BuildNavObject(ctx.Navigation, page.Route, pageRoutes, bp), false);
so.SetValue("breadcrumbs", BuildBreadcrumbs(page.Route, page.FrontMatter.Title, ctx.Navigation, pageRoutes, bp), false);
#endregion
// Partials (injected as strings so templates can use {{ partials.head }})
so.SetValue("partials", BuildPartialsObject(ctx), false);
// CSS/JS paths (prefixed with base path)
so.SetValue("css_files", ctx.CssFiles.Select(f => PrefixRoute(f, bp)).ToList(), false);
so.SetValue("js_files", ctx.JsFiles.Select(f => PrefixRoute(f, bp)).ToList(), false);
#region Version Data
if (ctx.Versions.Count > 0)
{
var versionsArray = new ScriptArray();
foreach (DocVersion v in ctx.Versions)
{
versionsArray.Add(new ScriptObject { { "label", v.Label }, { "slug", v.Slug }, { "is_default", v.IsDefault }, { "is_prerelease", v.IsPrerelease } });
}
so.SetValue("versions", versionsArray, false);
so.SetValue("current_version", ctx.CurrentVersion?.Label ?? "", false);
}
else
{
so.SetValue("versions", new ScriptArray(), false);
so.SetValue("current_version", "", false);
}
#endregion
// Package metadata for NuGet install widget
if (ctx.PackageInfo is { } pkg)
{
so.SetValue("package", new ScriptObject { { "name", pkg.Name }, { "version", pkg.Version } }, false);
}
#region Prev/Next Page Navigation
var orderedPages = ctx.AllPages.Where(p => p.FrontMatter.Visibility == PageVisibility.Public && p.FrontMatter.Layout == "default").OrderBy(p => p.FrontMatter.Order).ThenBy(p => p.Route, StringComparer.OrdinalIgnoreCase).ToList();
int currentIndex = orderedPages.FindIndex(p => p.Route == page.Route);
if (currentIndex >= 0)
{
if (currentIndex > 0)
{
DocPage prev = orderedPages[currentIndex - 1];
so.SetValue("prev_page", new ScriptObject { { "title", prev.FrontMatter.Title }, { "route", PrefixRoute(prev.Route, bp) } }, false);
}
if (currentIndex < orderedPages.Count - 1)
{
DocPage next = orderedPages[currentIndex + 1];
so.SetValue("next_page", new ScriptObject { { "title", next.FrontMatter.Title }, { "route", PrefixRoute(next.Route, bp) } }, false);
}
}
#endregion
return so;
}
private static ScriptArray BuildTocObject(TableOfContents toc)
{
var arr = new ScriptArray();
foreach (TocEntry entry in toc.Entries)
{
arr.Add(BuildTocEntryObject(entry));
}
return arr;
}
private static ScriptObject BuildTocEntryObject(TocEntry entry)
{
var obj = new ScriptObject
{
{
"level",
entry.Level
},
{
"text",
entry.Text
},
{
"id",
entry.Id
}
};
var children = new ScriptArray();
foreach (TocEntry child in entry.Children)
{
children.Add(BuildTocEntryObject(child));
}
obj.SetValue("children", children, false);
return obj;
}
private static ScriptArray BuildNavObject(NavigationTree? nav, string activeRoute, HashSet<string> pageRoutes, string basePath)
{
if (nav is null)
{
return[];
}
var arr = new ScriptArray();
foreach (NavigationNode node in nav.Items)
{
arr.Add(BuildNavNodeObject(node, activeRoute, pageRoutes, basePath));
}
return arr;
}
private static ScriptObject BuildNavNodeObject(NavigationNode node, string activeRoute, HashSet<string> pageRoutes, string basePath)
{
bool hasActiveChild = HasActiveDescendant(node, activeRoute);
bool hasChildren = node.Children.Count > 0;
bool hasPage = !string.IsNullOrEmpty(node.Route) && pageRoutes.Contains(node.Route);
// A node is "active" only if its route matches AND it doesn't have an active child.
// This prevents parent sections from showing as "current" when their route was
// resolved to a child's route (e.g. /guide → /guide/getting-started).
bool isActive = node.Route == activeRoute && !hasActiveChild;
var obj = new ScriptObject
{
{
"label",
node.Label
},
{
"route",
!string.IsNullOrEmpty(node.Route) ? PrefixRoute(node.Route, basePath) : ""
},
{
"icon",
ResolveIcon(node.Icon)
},
{
"expanded",
node.Expanded || hasActiveChild
},
{
"is_active",
isActive
},
{
"has_active_child",
hasActiveChild
},
{
"has_children",
hasChildren
},
{
"has_page",
hasPage
}
};
var children = new ScriptArray();
foreach (NavigationNode child in node.Children)
{
children.Add(BuildNavNodeObject(child, activeRoute, pageRoutes, basePath));
}
obj.SetValue("children", children, false);
return obj;
}
private static bool HasActiveDescendant(NavigationNode node, string activeRoute)
{
foreach (NavigationNode child in node.Children)
{
if (child.Route == activeRoute)
{
return true;
}
if (HasActiveDescendant(child, activeRoute))
{
return true;
}
}
return false;
}
private static ScriptArray BuildSocialLinks(List<SocialLink> links)
{
var arr = new ScriptArray();
foreach (SocialLink link in links)
{
string iconSvg = LucideIcons.Get(link.Icon) ?? link.Icon;
arr.Add(new ScriptObject { { "icon", link.Icon }, { "url", link.Url }, { "icon_svg", iconSvg } });
}
return arr;
}
private static ScriptArray BuildBreadcrumbs(string route, string pageTitle, NavigationTree? nav, HashSet<string> pageRoutes, string basePath)
{
var crumbs = new ScriptArray();
// Always start with Home
crumbs.Add(new ScriptObject { { "label", "Home" }, { "url", PrefixRoute("/", basePath) }, { "is_current", false } });
string[] segments = route.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
{
return crumbs;
}
// Build intermediate crumbs from route segments
string pathSoFar = "";
for (int i = 0; i < segments.Length - 1; i++)
{
pathSoFar += "/" + segments[i];
// Try to find a label from the nav tree
string label = FindNavLabel(nav, pathSoFar) ?? FormatSegment(segments[i]);
// Only make it a link if a real page exists at this route (avoid linking to redirects)
bool hasPage = pageRoutes.Contains(pathSoFar);
crumbs.Add(new ScriptObject { { "label", label }, { "url", hasPage ? PrefixRoute(pathSoFar + "/", basePath) : "" }, { "is_current", false }, { "has_page", hasPage } });
}
// Current page
crumbs.Add(new ScriptObject { { "label", pageTitle }, { "url", PrefixRoute(route, basePath) }, { "is_current", true }, { "has_page", true } });
return crumbs;
}
private static string? FindNavLabel(NavigationTree? nav, string route)
{
if (nav is null)
{
return null;
}
return FindNavLabelInNodes(nav.Items, route);
}
private static string? FindNavLabelInNodes(List<NavigationNode> nodes, string route)
{
foreach (NavigationNode node in nodes)
{
if (string.Equals(node.Route, route, StringComparison.OrdinalIgnoreCase))
{
return node.Label;
}
string? childResult = FindNavLabelInNodes(node.Children, route);
if (childResult is not null)
{
return childResult;
}
}
return null;
}
private static string ResolveIcon(string? iconName)
{
if (string.IsNullOrEmpty(iconName))
{
return "";
}
return LucideIcons.Get(iconName) ?? "";
}
private static string FormatSegment(string segment)
{
if (string.IsNullOrEmpty(segment))
{
return "";
}
string formatted = segment.Replace('-', ' ');
return char.ToUpper(formatted[0]) + formatted[1..];
}
private static ScriptObject BuildPartialsObject(ThemeRenderContext ctx)
{
var obj = new ScriptObject();
foreach ((string name, string content)in ctx.Partials)
// Parse and render partials as-is (they'll be included raw)
{
obj.SetValue(name, content, false);
}
return obj;
}
/// <summary>
/// Fixes whitespace inside <c><pre></c> blocks that gets added by Scriban template indentation.
/// Finds the common leading whitespace on lines within each pre block and strips it.
/// </summary>
private static string FixPreBlockIndentation(string html)
{
const string preOpen = "<pre>";
const string preClose = "</pre>";
var result = new StringBuilder(html.Length);
int pos = 0;
while (pos < html.Length)
{
int preStart = html.IndexOf(preOpen, pos, StringComparison.OrdinalIgnoreCase);
if (preStart < 0)
{
result.Append(html, pos, html.Length - pos);
break;
}
// Copy everything before <pre>
result.Append(html, pos, preStart - pos);
int contentStart = preStart + preOpen.Length;
// Find matching </pre> — handle <pre ...> with attributes too
int actualPreEnd = html.IndexOf('>', preStart);
if (actualPreEnd < 0)
{
result.Append(html, preStart, html.Length - preStart);
break;
}
contentStart = actualPreEnd + 1;
int preEnd = html.IndexOf(preClose, contentStart, StringComparison.OrdinalIgnoreCase);
if (preEnd < 0)
{
result.Append(html, preStart, html.Length - preStart);
break;
}
// Extract pre content and strip common leading whitespace
string preTag = html[preStart..contentStart];
string content = html[contentStart..preEnd];
string stripped = StripCommonIndent(content);
result.Append(preTag);
result.Append(stripped);
result.Append(preClose);
pos = preEnd + preClose.Length;
}
return result.ToString();
}
private static string StripCommonIndent(string text)
{
string[] lines = text.Split('\n');
if (lines.Length <= 1)
{
return text;
}
// Find minimum indentation across non-empty lines (skip first line which is on the <pre> line)
int minIndent = int.MaxValue;
for (int i = 1; i < lines.Length; i++)
{
string line = lines[i];
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
int indent = 0;
while (indent < line.Length && line[indent] == ' ')
{
indent++;
}
if (indent < minIndent)
{
minIndent = indent;
}
}
if (minIndent == 0 || minIndent == int.MaxValue)
{
return text;
}
// Strip the common indent from all lines except the first
var sb = new StringBuilder();
sb.Append(lines[0]);
for (int i = 1; i < lines.Length; i++)
{
sb.Append('\n');
if (lines[i].Length > minIndent)
{
sb.Append(lines[i][minIndent..]);
}
else
{
sb.Append(lines[i].TrimStart());
}
}
return sb.ToString();
}
}