Class Sealed
public sealed class ScribanTemplateEngine

Namespace: Moka.Docs.Rendering.Scriban

Renders pages using Scriban templates from the active theme.

Constructors

NameDescription
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

NameDescription
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

NameTypeDescription
pageDocPageThe page to render.
themeContextMoka.Docs.Rendering.Scriban.ThemeRenderContextThe 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>&lt;pre&gt;</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();
    }
}
Was this page helpful?