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
| Name | Description |
|---|---|
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
Methods
| Name | Description |
|---|---|
ExecuteAsync(BuildContext context, CancellationToken ct) |
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";
}
}