Class Sealed
public sealed class OutputPhase : IBuildPhase

Namespace: Moka.Docs.Engine.Phases

Writes all rendered pages and copies assets to the output directory. Handles output directory creation and cleaning.

Inheritance

Inherits from: IBuildPhase

Constructors

NameDescription
OutputPhase(OutputPhase> logger) Writes all rendered pages and copies assets to the output directory. Handles output directory creation and cleaning.

OutputPhase(OutputPhase> logger)

OutputPhase.OutputPhase(ILogger<OutputPhase> logger)

Writes all rendered pages and copies assets to the output directory. Handles output directory creation and cleaning.

Properties

NameDescription
Name
Order

Methods

Type Relationships
classDiagram
                    style OutputPhase fill:#f9f,stroke:#333,stroke-width:2px
                    OutputPhase --|> IBuildPhase : inherits
                
View Source
/// <summary>
///     Writes all rendered pages and copies assets to the output directory.
///     Handles output directory creation and cleaning.
/// </summary>
public sealed class OutputPhase(ILogger<OutputPhase> logger) : IBuildPhase
{
    private static readonly JsonSerializerOptions _searchJsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        WriteIndented = false
    };
    /// <inheritdoc/>
    public string Name => "Output";
    /// <inheritdoc/>
    public int Order => 1100;

    /// <inheritdoc/>
    public Task ExecuteAsync(BuildContext context, CancellationToken ct = default)
    {
        IFileSystem fs = context.FileSystem;
        string outputDir = context.OutputDirectory;
        // Clean output directory if configured
        if (context.Config.Build.Clean && fs.Directory.Exists(outputDir))
        {
            logger.LogInformation("Cleaning output directory: {Path}", outputDir);
            fs.Directory.Delete(outputDir, true);
        }

        fs.Directory.CreateDirectory(outputDir);
        // Write each page as an HTML file
        int writtenCount = 0;
        foreach (DocPage page in context.Pages)
        {
            ct.ThrowIfCancellationRequested();
            if (page.FrontMatter.Visibility == PageVisibility.Draft)
            {
                continue;
            }

            string pagePath = RouteToFilePath(page.Route);
            string fullPath = fs.Path.Combine(outputDir, pagePath);
            string dir = fs.Path.GetDirectoryName(fullPath)!;
            fs.Directory.CreateDirectory(dir);
            // Write the fully rendered HTML (template applied by RenderPhase)
            fs.File.WriteAllText(fullPath, page.Content.Html);
            writtenCount++;
        }

        // Generate section index pages (redirects) for directories without index.html
        int redirectCount = GenerateSectionIndexPages(context);
        // Write 404 page
        Write404Page(context);
        // Write search index JSON
        WriteSearchIndex(context);
        // Copy static assets
        int assetsCopied = CopyAssets(context);
        // Write deferred files registered by plugins (run after clean so they survive)
        int deferredFiles = WriteDeferredFiles(context);
        int deferredDirs = CopyDeferredDirectories(context);
        logger.LogInformation("Wrote {Pages} pages, {Redirects} section redirects, {Assets} assets, {DeferredFiles} deferred files, {DeferredDirs} deferred directories to {Output}", writtenCount, redirectCount, assetsCopied, deferredFiles, deferredDirs, outputDir);
        return Task.CompletedTask;
    }

    private int CopyAssets(BuildContext context)
    {
        IFileSystem fs = context.FileSystem;
        string docsPath = fs.Path.GetFullPath(fs.Path.Combine(context.RootDirectory, context.Config.Content.Docs));
        string outputDir = context.OutputDirectory;
        int count = 0;
        foreach (string assetPath in context.DiscoveredAssetFiles)
        {
            string sourcePath = fs.Path.Combine(docsPath, assetPath);
            string destPath = fs.Path.Combine(outputDir, assetPath);
            string destDir = fs.Path.GetDirectoryName(destPath)!;
            if (!fs.File.Exists(sourcePath))
            {
                continue;
            }

            fs.Directory.CreateDirectory(destDir);
            fs.File.Copy(sourcePath, destPath, true);
            count++;
        }

        // Brand assets (site.logo, site.favicon) can live OUTSIDE the content.docs tree
        // — e.g. at the mokadocs.yaml directory level, or above it via `../`. The
        // BrandAssetResolver has already resolved these to (publish URL → source path)
        // pairs during the Discovery phase; we just need to copy each one to its
        // publish location under the output dir.
        foreach (KeyValuePair<string, string> brand in context.BrandAssetFiles)
        {
            string publishUrl = brand.Key;
            string sourcePath = brand.Value;
            if (!fs.File.Exists(sourcePath))
            {
                logger.LogWarning("Brand asset source file disappeared between discovery and output: {Source}", sourcePath);
                continue;
            }

            // publishUrl always starts with "/"; strip it to join cleanly with outputDir.
            string relative = publishUrl.TrimStart('/');
            string destPath = fs.Path.Combine(outputDir, relative);
            string destDir = fs.Path.GetDirectoryName(destPath)!;
            // Don't overwrite a file already copied by the main asset glob above (a user
            // whose logo IS inside content.docs will end up with the same file visible
            // through both code paths — the first one that wrote it wins, and that's
            // always the glob since it runs first).
            if (fs.File.Exists(destPath))
            {
                continue;
            }

            fs.Directory.CreateDirectory(destDir);
            fs.File.Copy(sourcePath, destPath, true);
            count++;
        }

        return count;
    }

    private int WriteDeferredFiles(BuildContext context)
    {
        if (context.DeferredOutputFiles.Count == 0)
        {
            return 0;
        }

        IFileSystem fs = context.FileSystem;
        int count = 0;
        foreach (KeyValuePair<string, byte[]> entry in context.DeferredOutputFiles)
        {
            string destFile = fs.Path.Combine(context.OutputDirectory, entry.Key);
            fs.Directory.CreateDirectory(fs.Path.GetDirectoryName(destFile)!);
            fs.File.WriteAllBytes(destFile, entry.Value);
            count++;
        }

        logger.LogInformation("Wrote {Count} deferred output file(s)", count);
        return count;
    }

    private int CopyDeferredDirectories(BuildContext context)
    {
        if (context.DeferredOutputDirectories.Count == 0)
        {
            return 0;
        }

        IFileSystem fs = context.FileSystem;
        int totalFiles = 0;
        foreach ((string sourceDir, string destRelPath)in context.DeferredOutputDirectories)
        {
            if (!Directory.Exists(sourceDir))
            {
                logger.LogWarning("Deferred output directory not found, skipping: {SourceDir}", sourceDir);
                continue;
            }

            string destDir = fs.Path.Combine(context.OutputDirectory, destRelPath);
            fs.Directory.CreateDirectory(destDir);
            foreach (string sourceFile in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
            {
                string relative = Path.GetRelativePath(sourceDir, sourceFile);
                string destFile = fs.Path.Combine(destDir, relative);
                fs.Directory.CreateDirectory(fs.Path.GetDirectoryName(destFile)!);
                fs.File.Copy(sourceFile, destFile, true);
                totalFiles++;
            }
        }

        logger.LogInformation("Copied {Count} deferred output directory(s) ({Files} files total)", context.DeferredOutputDirectories.Count, totalFiles);
        return context.DeferredOutputDirectories.Count;
    }

    private void WriteSearchIndex(BuildContext context)
    {
        SearchIndex? searchIndex = context.SearchIndex;
        if (searchIndex is null || searchIndex.Count == 0)
        {
            logger.LogDebug("No search index to write");
            return;
        }

        IFileSystem fs = context.FileSystem;
        string outputPath = fs.Path.Combine(context.OutputDirectory, "search-index.json");
        // Serialize entries with compact field names for smaller payload
        string bp = context.Config.Build.BasePath;
        var entries = searchIndex.Entries.Select(e => new { t = e.Title, s = e.Section ?? "", r = bp == "/" ? e.Route : bp + e.Route, c = e.Content.Length > 300 ? e.Content[..300] : e.Content, g = e.Category });
        string json = JsonSerializer.Serialize(entries, _searchJsonOptions);
        fs.File.WriteAllText(outputPath, json);
        logger.LogInformation("Wrote search index ({Count} entries, {Size} bytes)", searchIndex.Count, json.Length);
    }

    private void Write404Page(BuildContext context)
    {
        IFileSystem fs = context.FileSystem;
        SiteConfig config = context.Config;
        string path = fs.Path.Combine(context.OutputDirectory, "404.html");
        string bp = config.Build.BasePath == "/" ? "" : config.Build.BasePath;
        string html = $"""
		               <!DOCTYPE html>
		               <html lang="en" data-theme="light" data-code-theme="{config.Theme.Options.CodeTheme}">
		               <head>
		                   <meta charset="utf-8" />
		                   <meta name="viewport" content="width=device-width, initial-scale=1" />
		                   <title>Page Not Found — {HttpUtility.HtmlEncode(config.Site.Title)}</title>
		                   <link rel="stylesheet" href="{bp}/_theme/css/main.css" />
		               </head>
		               <body>
		                   <header class="site-header">
		                       <div class="header-inner">
		                           <a class="site-logo" href="{bp}/">
		                               <span class="site-name">{HttpUtility.HtmlEncode(config.Site.Title)}</span>
		                           </a>
		                           <div class="header-actions">
		                               <button class="theme-toggle" aria-label="Toggle dark mode">
		                                   <svg class="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
		                                   <svg class="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
		                               </button>
		                           </div>
		                       </div>
		                   </header>
		                   <main style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:calc(100vh - 200px);text-align:center;padding:2rem;">
		                       <h1 style="font-size:6rem;font-weight:800;color:var(--color-primary);margin:0;line-height:1;">404</h1>
		                       <p style="font-size:1.25rem;color:var(--color-text-secondary);margin:1rem 0 2rem;">This page could not be found.</p>
		                       <a href="{bp}/" style="display:inline-flex;align-items:center;gap:0.5rem;padding:0.625rem 1.5rem;background:var(--color-primary);color:white;border-radius:var(--radius);font-weight:600;text-decoration:none;transition:opacity 150ms;">
		                           ← Back to Home
		                       </a>
		                   </main>
		                   <footer class="site-footer">
		                       <div class="footer-inner">
		                           <span class="built-with">Built with <a href="https://github.com/jacobwi/Moka.Docs">MokaDocs</a></span>
		                       </div>
		                   </footer>
		                   <script src="{bp}/_theme/js/main.js"></script>
		               </body>
		               </html>
		               """;
        fs.File.WriteAllText(path, html);
        logger.LogInformation("Generated 404.html");
    }

    private int GenerateSectionIndexPages(BuildContext context)
    {
        IFileSystem fs = context.FileSystem;
        string outputDir = context.OutputDirectory;
        int count = 0;
        // Collect all directories under the output that contain at least one
        // subdirectory with an index.html, but don't have their own index.html.
        var dirsWithIndex = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        // Walk all written page routes to find directories that have an index.html
        foreach (DocPage page in context.Pages)
        {
            if (page.FrontMatter.Visibility == PageVisibility.Draft)
            {
                continue;
            }

            string filePath = RouteToFilePath(page.Route);
            string fullPath = fs.Path.Combine(outputDir, filePath);
            string dir = fs.Path.GetDirectoryName(fullPath)!;
            dirsWithIndex.Add(dir);
        }

        // For each directory that has an index.html, check whether its parent
        // directory is missing an index.html. If so, generate a redirect.
        var processed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (string dir in dirsWithIndex)
        {
            // Walk up from each page directory, generating redirects for any
            // ancestor (up to but not including the output root) that lacks index.html.
            string? parent = fs.Path.GetDirectoryName(dir);
            while (parent != null && parent.Length > outputDir.Length && parent.StartsWith(outputDir, StringComparison.OrdinalIgnoreCase))
            {
                if (!processed.Add(parent))
                {
                    break; // already handled this parent
                }

                string parentIndex = fs.Path.Combine(parent, "index.html");
                if (fs.File.Exists(parentIndex))
                // This directory already has an index page; stop walking up.
                {
                    break;
                }

                // Find the first child subdirectory (alphabetically) that has an index.html
                var childDirs = fs.Directory.GetDirectories(parent).OrderBy(d => fs.Path.GetFileName(d), StringComparer.OrdinalIgnoreCase).ToList();
                string? targetSubDir = null;
                foreach (string child in childDirs)
                {
                    string childIndex = fs.Path.Combine(child, "index.html");
                    if (fs.File.Exists(childIndex))
                    {
                        targetSubDir = fs.Path.GetFileName(child);
                        break;
                    }
                }

                if (targetSubDir is null)
                {
                    // No child with an index page; skip.
                    parent = fs.Path.GetDirectoryName(parent);
                    continue;
                }

                string redirectUrl = $"./{targetSubDir}/";
                string redirectHtml = $"""
				                       <!DOCTYPE html>
				                       <html>
				                       <head><meta http-equiv="refresh" content="0; url={redirectUrl}"><link rel="canonical" href="{redirectUrl}"></head>
				                       <body><p>Redirecting to <a href="{redirectUrl}">{redirectUrl}</a>...</p></body>
				                       </html>
				                       """;
                fs.File.WriteAllText(parentIndex, redirectHtml);
                count++;
                string relativePath = parentIndex[outputDir.Length..].TrimStart(fs.Path.DirectorySeparatorChar, fs.Path.AltDirectorySeparatorChar);
                logger.LogDebug("Generated section redirect: {Path} -> {Target}", relativePath, redirectUrl);
                parent = fs.Path.GetDirectoryName(parent);
            }
        }

        if (count > 0)
        {
            logger.LogInformation("Generated {Count} section index redirect(s)", count);
        }

        return count;
    }

    private static string RouteToFilePath(string route)
    {
        string path = route.Trim('/');
        if (string.IsNullOrEmpty(path))
        {
            return "index.html";
        }

        return path + "/index.html";
    }
}
Was this page helpful?