lib/zen/src/site/publish.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

   1 const std = @import("std");
   2 const zen = @import("../root.zig");
   3 const diagnostic = zen.diagnostic;
   4 const document = zen.document;
   5 const footnote = zen.footnote;
   6 const frontmatter = zen.frontmatter;
   7 const markdown = zen.markdown;
   8 const quiz = zen.quiz;
   9 const site_catalog = @import("catalog/root.zig");
  10 const site_page = @import("page/root.zig");
  11 const site_path = @import("path/root.zig");
  12 const theme = zen.theme;
  13 
  14 const Allocator = std.mem.Allocator;
  15 const io = std.Options.debug_io;
  16 
  17 pub const max_asset_bytes = 64 * 1024 * 1024;
  18 
  19 /// Optional validation or preparation step over the complete staging directory.
  20 pub const BeforePublish = struct {
  21     context: *anyopaque,
  22     run: *const fn (*anyopaque, Allocator, []const u8) anyerror!void,
  23 };
  24 
  25 /// Controls one source-tree to static-site publication.
  26 pub const BuildOptions = struct {
  27     /// Existing source directory with Markdown, assets, and optional `.zen` files.
  28     source: []const u8,
  29     /// Destination directory. With `clean`, publication replaces it atomically.
  30     output: []const u8,
  31     /// Root-relative URL prefix such as `/docs`; use empty text at a host root.
  32     base: []const u8 = "",
  33     /// Hosted parent prefix whose root-relative references remain unchanged.
  34     parent_site_base: ?[]const u8 = null,
  35     /// In-memory theme used when source theme files do not override it.
  36     theme: theme.Theme = .{},
  37     /// Bounds HTML produced by the page theme.
  38     theme_render_limits: theme.RenderLimits = theme.default_render_limits,
  39     /// Builds in staging and replaces prior output when true.
  40     clean: bool = true,
  41     /// Writes `page/index.html` routes when true and `page.html` when false.
  42     clean_urls: bool = true,
  43     /// Copies hidden source paths other than the owned `.zen` theme path.
  44     copy_hidden: bool = false,
  45     /// Excludes README Markdown pages when true.
  46     skip_readme: bool = false,
  47     /// Source-relative prefixes omitted from pages and assets.
  48     exclude: []const []const u8 = &.{},
  49     /// Includes pages whose frontmatter sets `draft: true`.
  50     include_drafts: bool = false,
  51     /// Includes non-curated pages and marks them as slop.
  52     include_slop: bool = true,
  53     /// Default Markdown render mode unless page metadata selects slides.
  54     render_mode: markdown.Mode = .document,
  55     /// Publishes the source-bearing authored route manifest for local editing.
  56     publish_authored_routes: bool = false,
  57     /// Keeps one exact call-map stylesheet per rendered page when true.
  58     one_call_map_style_per_page: bool = true,
  59     /// Selects bounded theme files inside the source tree.
  60     theme_files: theme.Files = .{},
  61     /// Bounds retained page records and catalog text.
  62     catalog_limits: site_catalog.Limits = site_catalog.default_limits,
  63     /// Bounds the largest Markdown page read into reusable storage.
  64     page_limits: site_page.Limits = site_page.default_limits,
  65     /// Maximum bytes copied from one asset.
  66     asset_limit: usize = max_asset_bytes,
  67     /// Bounds frontmatter pairs and joined continuation bytes.
  68     frontmatter_limits: frontmatter.Limits = .{
  69         .max_pairs = site_page.default_limits.max_page_bytes,
  70         .max_continuation_bytes = site_page.default_limits.max_page_bytes,
  71     },
  72     /// Bounds headings per Markdown page.
  73     heading_limits: document.Limits = document.default_limits,
  74     /// Bounds footnotes per Markdown page.
  75     footnote_limits: footnote.Limits = footnote.default_limits,
  76     /// Bounds each quiz fence in a Markdown page.
  77     quiz_limits: quiz.Limits = quiz.default_limits,
  78     /// Bounds transformed target paths and public URLs.
  79     path_limits: site_path.Limits = site_path.default_limits,
  80     /// Receives source-relative page and repair detail after supported failures.
  81     diagnostic: ?*diagnostic.Diagnostic = null,
  82     /// Runs on the complete staging site before it can replace current output.
  83     before_publish: ?BeforePublish = null,
  84 };
  85 
  86 /// Counts the visible work completed by a successful site publication.
  87 pub const BuildResult = struct {
  88     /// Rendered Markdown pages.
  89     pages: usize = 0,
  90     /// Copied assets plus a generated theme stylesheet when present.
  91     assets: usize = 0,
  92     /// Draft pages skipped because `include_drafts` is false.
  93     drafts: usize = 0,
  94     /// Non-curated pages skipped because `include_slop` is false.
  95     slop: usize = 0,
  96     /// Total source bytes read.
  97     bytes_read: u64 = 0,
  98     /// Total site bytes written.
  99     bytes_written: u64 = 0,
 100 };
 101 
 102 const FrontmatterParse = struct {
 103     storage: frontmatter.Storage,
 104     parsed: frontmatter.Parsed,
 105 
 106     fn init(
 107         allocator: Allocator,
 108         source: []const u8,
 109         limits: frontmatter.Limits,
 110     ) !FrontmatterParse {
 111         const plan = try frontmatter.Plan.inspect(source, limits);
 112         var storage = try frontmatter.Storage.init(allocator, plan.exactLimits());
 113         errdefer storage.deinit(allocator);
 114         storage.activate();
 115         const parsed = try frontmatter.parse(&storage, source);
 116         return .{ .storage = storage, .parsed = parsed };
 117     }
 118 
 119     fn deinit(self: *FrontmatterParse, allocator: Allocator) void {
 120         self.storage.reset();
 121         self.storage.deinit(allocator);
 122         self.* = undefined;
 123     }
 124 };
 125 
 126 const PageListOptions = struct {
 127     prefix: []const u8 = "",
 128     title: []const u8 = "",
 129     include_index: bool = false,
 130     curated: CuratedFilter = .all,
 131     types: bool = false,
 132     format: PageListFormat = .list,
 133 };
 134 
 135 const CuratedFilter = enum {
 136     all,
 137     only,
 138     exclude,
 139 };
 140 
 141 const PageListFormat = enum {
 142     list,
 143     table,
 144 };
 145 
 146 /// Site-option validation failures; rendering, theme, storage, and I/O errors propagate.
 147 pub const Error = error{
 148     /// `source` is empty.
 149     InvalidSource,
 150     /// `output` is empty.
 151     InvalidOutput,
 152     /// `base` is nonempty but is not root-relative or ends in `/`.
 153     InvalidBase,
 154     /// `parent_site_base` does not own `base` at an exact route boundary.
 155     InvalidParentSiteBase,
 156     /// The output directory is inside the source tree.
 157     OutputInsideSource,
 158     /// The selected render mode is not supported by the site builder.
 159     InvalidRenderMode,
 160     /// A pre-publish hook requires atomic `clean` publication.
 161     PublishHookRequiresClean,
 162     /// The source-bearing authored route manifest requires atomic `clean` publication.
 163     AuthoredRouteManifestRequiresClean,
 164     /// The selected theme stylesheet conflicts with the authored route manifest.
 165     AuthoredRouteManifestPathConflict,
 166 };
 167 
 168 pub fn buildSite(allocator: Allocator, options: BuildOptions) !BuildResult {
 169     if (options.source.len == 0) return error.InvalidSource;
 170     if (options.base.len != 0 and
 171         (options.base[0] != '/' or options.base[options.base.len - 1] == '/')) return error.InvalidBase;
 172     if (options.parent_site_base) |parent_site_base| {
 173         if (!parentSiteOwnsBase(parent_site_base, options.base)) {
 174             return error.InvalidParentSiteBase;
 175         }
 176     }
 177     if (options.output.len == 0) return error.InvalidOutput;
 178     if (!options.clean and options.before_publish != null) {
 179         return error.PublishHookRequiresClean;
 180     }
 181     if (!options.clean and options.publish_authored_routes) {
 182         return error.AuthoredRouteManifestRequiresClean;
 183     }
 184     const source_root = try realPathOwned(allocator, options.source);
 185     defer allocator.free(source_root);
 186 
 187     const planned_output_root = try plannedOutputRoot(allocator, options.output);
 188     defer allocator.free(planned_output_root);
 189     if (insideRoot(source_root, planned_output_root)) return error.OutputInsideSource;
 190 
 191     var loaded_theme = try theme.load(allocator, source_root, options.theme_files, options.theme);
 192     defer loaded_theme.deinit(allocator);
 193     var active_options = options;
 194     active_options.theme = loaded_theme.value;
 195     if (active_options.publish_authored_routes and active_options.theme.style.len != 0 and
 196         std.mem.eql(u8, active_options.theme.style_path, site_catalog.manifest_path))
 197     {
 198         return error.AuthoredRouteManifestPathConflict;
 199     }
 200 
 201     if (!options.clean) {
 202         try std.Io.Dir.cwd().createDirPath(io, options.output);
 203         const output_root = try realPathOwned(allocator, options.output);
 204         defer allocator.free(output_root);
 205         if (insideRoot(source_root, output_root)) return error.OutputInsideSource;
 206         return try buildInto(allocator, active_options, source_root, output_root);
 207     }
 208 
 209     const staging = try stagingPath(allocator, options.output);
 210     defer allocator.free(staging);
 211     const backup = try backupPath(allocator, options.output);
 212     defer allocator.free(backup);
 213 
 214     if (pathExists(staging)) try deleteOutput(staging);
 215     try std.Io.Dir.cwd().createDirPath(io, staging);
 216     errdefer if (pathExists(staging)) deleteOutput(staging) catch {};
 217 
 218     const output_root = try realPathOwned(allocator, staging);
 219     defer allocator.free(output_root);
 220     if (insideRoot(source_root, output_root)) return error.OutputInsideSource;
 221 
 222     const result = try buildInto(allocator, active_options, source_root, output_root);
 223     if (options.before_publish) |hook| {
 224         try hook.run(hook.context, allocator, output_root);
 225     }
 226     try swapOutput(options.output, staging, backup);
 227     return result;
 228 }
 229 
 230 fn buildInto(
 231     allocator: Allocator,
 232     options: BuildOptions,
 233     source_root: []const u8,
 234     output_root: []const u8,
 235 ) !BuildResult {
 236     if (insideRoot(source_root, output_root)) return error.OutputInsideSource;
 237 
 238     var source_dir = try std.Io.Dir.openDirAbsolute(io, source_root, .{ .iterate = true });
 239     defer source_dir.close(io);
 240     var output_dir = try std.Io.Dir.openDirAbsolute(io, output_root, .{});
 241     defer output_dir.close(io);
 242     const page_plan = try planPageStorage(allocator, options, &source_dir);
 243     var page_storage = try site_page.Storage.init(allocator, page_plan.exactLimits());
 244     defer page_storage.deinit(allocator);
 245     page_storage.activate();
 246     var path_storage = try site_path.Storage.init(allocator, options.path_limits);
 247     defer path_storage.deinit(allocator);
 248     path_storage.activate();
 249 
 250     const catalog_plan = try planCatalog(
 251         allocator,
 252         options,
 253         &source_dir,
 254         &page_storage,
 255         &path_storage,
 256     );
 257     var catalog_storage = try site_catalog.Storage.init(
 258         allocator,
 259         catalog_plan.exactLimits(),
 260     );
 261     defer catalog_storage.deinit(allocator);
 262     catalog_storage.activate();
 263     const catalog = try collectCatalog(
 264         allocator,
 265         options,
 266         &source_dir,
 267         &page_storage,
 268         &path_storage,
 269         &catalog_storage,
 270     );
 271 
 272     var result = try renderSourceFiles(
 273         allocator,
 274         options,
 275         &source_dir,
 276         &output_dir,
 277         &page_storage,
 278         &path_storage,
 279         catalog,
 280     );
 281     try writeThemeStyle(options, &output_dir, &result);
 282     if (options.publish_authored_routes) {
 283         try writeAuthoredRouteManifest(catalog, &output_dir, &result);
 284     }
 285     return result;
 286 }
 287 
 288 fn writeAuthoredRouteManifest(
 289     catalog: site_catalog.Catalog,
 290     output_dir: *std.Io.Dir,
 291     result: *BuildResult,
 292 ) !void {
 293     if (std.fs.path.dirname(site_catalog.manifest_path)) |parent| {
 294         try output_dir.createDirPath(io, parent);
 295     }
 296     var file = try output_dir.createFile(io, site_catalog.manifest_path, .{ .truncate = true });
 297     defer file.close(io);
 298     var buffer: [4096]u8 = undefined;
 299     var writer = file.writerStreaming(io, &buffer);
 300     try site_catalog.writeManifest(catalog, &writer.interface);
 301     try writer.interface.flush();
 302     result.bytes_written += try site_catalog.manifestEncodedSize(catalog);
 303 }
 304 
 305 fn renderSourceFiles(
 306     allocator: Allocator,
 307     options: BuildOptions,
 308     source_dir: *std.Io.Dir,
 309     output_dir: *std.Io.Dir,
 310     page_storage: *site_page.Storage,
 311     path_storage: *site_path.Storage,
 312     catalog: site_catalog.Catalog,
 313 ) !BuildResult {
 314     var walker = try source_dir.walk(allocator);
 315     defer walker.deinit();
 316 
 317     var result: BuildResult = .{};
 318     while (try walker.next(io)) |entry| {
 319         if (entry.kind != .file) continue;
 320         if (!includedSourcePath(options, entry.path)) continue;
 321         if (isMarkdown(entry.path)) {
 322             const page_paths = path_storage.acquire(
 323                 entry.path,
 324                 options.clean_urls,
 325                 .target,
 326             ) catch |err| {
 327                 if (options.diagnostic) |d| d.setPage(entry.path);
 328                 return err;
 329             };
 330             defer path_storage.reset();
 331             buildPage(
 332                 allocator,
 333                 options,
 334                 source_dir,
 335                 output_dir,
 336                 page_storage,
 337                 entry.path,
 338                 page_paths,
 339                 catalog,
 340                 &result,
 341             ) catch |err| {
 342                 if (options.diagnostic) |d| d.setPage(entry.path);
 343                 return err;
 344             };
 345         } else {
 346             try copyAsset(allocator, options, source_dir, output_dir, entry.path, &result);
 347         }
 348     }
 349     return result;
 350 }
 351 
 352 fn writeThemeStyle(
 353     options: BuildOptions,
 354     output_dir: *std.Io.Dir,
 355     result: *BuildResult,
 356 ) !void {
 357     if (options.theme.style.len != 0 and options.theme.style_path.len != 0) {
 358         if (!pathExistsAt(output_dir, options.theme.style_path)) {
 359             if (std.fs.path.dirname(options.theme.style_path)) |parent| {
 360                 try output_dir.createDirPath(io, parent);
 361             }
 362             try output_dir.writeFile(io, .{
 363                 .sub_path = options.theme.style_path,
 364                 .data = options.theme.style,
 365                 .flags = .{ .truncate = true },
 366             });
 367             result.assets += 1;
 368             result.bytes_written += options.theme.style.len;
 369         }
 370     }
 371 }
 372 
 373 fn planPageStorage(
 374     allocator: Allocator,
 375     options: BuildOptions,
 376     source_dir: *std.Io.Dir,
 377 ) !site_page.Plan {
 378     var plan: site_page.Plan = .{};
 379     var walker = try source_dir.walk(allocator);
 380     defer walker.deinit();
 381 
 382     while (try walker.next(io)) |entry| {
 383         if (entry.kind != .file) continue;
 384         if (!includedSourcePath(options, entry.path)) continue;
 385         if (!isMarkdown(entry.path)) continue;
 386         const stat = source_dir.statFile(io, entry.path, .{}) catch |err| {
 387             if (options.diagnostic) |d| d.setPage(entry.path);
 388             return err;
 389         };
 390         plan.observe(stat.size, options.page_limits) catch |err| {
 391             if (options.diagnostic) |d| d.setPage(entry.path);
 392             return err;
 393         };
 394     }
 395     return plan;
 396 }
 397 
 398 fn planCatalog(
 399     allocator: Allocator,
 400     options: BuildOptions,
 401     source_dir: *std.Io.Dir,
 402     page_storage: *site_page.Storage,
 403     path_storage: *site_path.Storage,
 404 ) !site_catalog.Plan {
 405     var walker = try source_dir.walk(allocator);
 406     defer walker.deinit();
 407     var plan: site_catalog.Plan = .{};
 408 
 409     while (try walker.next(io)) |entry| {
 410         if (entry.kind != .file) continue;
 411         if (!includedSourcePath(options, entry.path)) continue;
 412         if (!isMarkdown(entry.path)) continue;
 413         const page_paths = path_storage.acquire(
 414             entry.path,
 415             options.clean_urls,
 416             .url,
 417         ) catch |err| {
 418             if (options.diagnostic) |d| d.setPage(entry.path);
 419             return err;
 420         };
 421         defer path_storage.reset();
 422         const bytes = page_storage.readFile(source_dir.*, io, entry.path) catch |err| {
 423             if (options.diagnostic) |d| d.setPage(entry.path);
 424             return err;
 425         };
 426         defer page_storage.release();
 427         const frontmatter_plan = frontmatter.Plan.inspect(
 428             bytes,
 429             options.frontmatter_limits,
 430         ) catch |err| {
 431             if (options.diagnostic) |d| d.setPage(entry.path);
 432             return err;
 433         };
 434         const projection = frontmatter_plan.projection;
 435         if (!catalogIncludes(options, projection.draft, !projection.curated)) continue;
 436         const demand = catalogDemand(entry.path, page_paths.public, projection);
 437         plan.observe(demand, options.catalog_limits) catch |err| {
 438             if (options.diagnostic) |d| d.setPage(entry.path);
 439             return err;
 440         };
 441     }
 442     return plan;
 443 }
 444 
 445 fn collectCatalog(
 446     allocator: Allocator,
 447     options: BuildOptions,
 448     source_dir: *std.Io.Dir,
 449     page_storage: *site_page.Storage,
 450     path_storage: *site_path.Storage,
 451     catalog_storage: *site_catalog.Storage,
 452 ) !site_catalog.Catalog {
 453     var walker = try source_dir.walk(allocator);
 454     defer walker.deinit();
 455 
 456     while (try walker.next(io)) |entry| {
 457         if (entry.kind != .file) continue;
 458         if (!includedSourcePath(options, entry.path)) continue;
 459         if (!isMarkdown(entry.path)) continue;
 460         const page_paths = path_storage.acquire(
 461             entry.path,
 462             options.clean_urls,
 463             .url,
 464         ) catch |err| {
 465             if (options.diagnostic) |d| d.setPage(entry.path);
 466             return err;
 467         };
 468         defer path_storage.reset();
 469         appendCatalogPage(
 470             allocator,
 471             options,
 472             source_dir,
 473             page_storage,
 474             catalog_storage,
 475             entry.path,
 476             page_paths.public,
 477         ) catch |err| {
 478             if (options.diagnostic) |d| d.setPage(entry.path);
 479             return err;
 480         };
 481     }
 482     return catalog_storage.seal(options.base);
 483 }
 484 
 485 fn appendCatalogPage(
 486     allocator: Allocator,
 487     options: BuildOptions,
 488     source_dir: *std.Io.Dir,
 489     page_storage: *site_page.Storage,
 490     catalog_storage: *site_catalog.Storage,
 491     relative_path: []const u8,
 492     public_url: []const u8,
 493 ) !void {
 494     const bytes = try page_storage.readFile(source_dir.*, io, relative_path);
 495     defer page_storage.release();
 496     var frontmatter_parse = try FrontmatterParse.init(
 497         allocator,
 498         bytes,
 499         options.frontmatter_limits,
 500     );
 501     defer frontmatter_parse.deinit(allocator);
 502     const parsed = frontmatter_parse.parsed;
 503     const draft = parsed.metadata.draft();
 504     const slop = !parsed.metadata.curated();
 505     if (!catalogIncludes(options, draft, slop)) return;
 506     const title_value = parsed.metadata.title() orelse
 507         markdownStem(std.fs.path.basename(relative_path));
 508     const dispatch = try site_catalog.resolveDispatch(
 509         parsed.metadata.get("render"),
 510         dispatchForRenderMode(options.render_mode),
 511     );
 512     _ = try catalog_storage.append(.{
 513         .relative_path = relative_path,
 514         .url = public_url,
 515         .title = title_value,
 516         .date = parsed.metadata.date() orelse "",
 517         .type_label = parsed.metadata.get("type") orelse "",
 518         .dispatch = dispatch,
 519         .draft = draft,
 520         .slop = slop,
 521     });
 522 }
 523 
 524 fn catalogDemand(
 525     relative_path: []const u8,
 526     public_url: []const u8,
 527     projection: frontmatter.MetadataProjection,
 528 ) site_catalog.Demand {
 529     const fallback = markdownStem(std.fs.path.basename(relative_path));
 530     return .{
 531         .relative_path_bytes = relative_path.len,
 532         .url_bytes = public_url.len,
 533         .title_bytes = projection.title_bytes orelse fallback.len,
 534         .date_bytes = projection.date_bytes orelse 0,
 535         .type_label_bytes = projection.type_bytes orelse 0,
 536     };
 537 }
 538 
 539 fn catalogIncludes(options: BuildOptions, draft: bool, slop: bool) bool {
 540     if (draft and !options.include_drafts) return false;
 541     if (slop and !options.include_slop) return false;
 542     return true;
 543 }
 544 
 545 test "site frontmatter owner keeps borrowed metadata valid" {
 546     comptime {
 547         @stardustClaim(
 548             @import("alloc_phase").capacity.witness(@import("../frontmatter/root.zig").Storage, "zen_frontmatter_consumer"),
 549             null,
 550             null,
 551             null,
 552             null,
 553             null,
 554             null,
 555         );
 556     }
 557 
 558     const source =
 559         "---\n" ++
 560         "title: Zen\n" ++
 561         "description:\n" ++
 562         "  bounded metadata\n" ++
 563         "---\n" ++
 564         "# Body\n";
 565     var parsed = try FrontmatterParse.init(std.testing.allocator, source, .{
 566         .max_pairs = 2,
 567         .max_continuation_bytes = "bounded metadata".len,
 568     });
 569     defer parsed.deinit(std.testing.allocator);
 570     try std.testing.expect(parsed.storage.status().in_use);
 571     try std.testing.expectEqualStrings("Zen", parsed.parsed.metadata.title().?);
 572     try std.testing.expectEqualStrings(
 573         "bounded metadata",
 574         parsed.parsed.metadata.description().?,
 575     );
 576     try std.testing.expectEqualStrings("# Body\n", parsed.parsed.body);
 577 }
 578 
 579 fn stagingPath(allocator: Allocator, output: []const u8) Allocator.Error![]u8 {
 580     return try std.fmt.allocPrint(allocator, "{s}.zen-tmp", .{output});
 581 }
 582 
 583 fn backupPath(allocator: Allocator, output: []const u8) Allocator.Error![]u8 {
 584     return try std.fmt.allocPrint(allocator, "{s}.zen-old", .{output});
 585 }
 586 
 587 fn swapOutput(output: []const u8, staging: []const u8, backup: []const u8) !void {
 588     if (pathExists(backup)) try deleteOutput(backup);
 589     var has_backup = false;
 590     if (pathExists(output)) {
 591         try renamePath(output, backup);
 592         has_backup = true;
 593     }
 594     errdefer if (has_backup and !pathExists(output) and pathExists(backup)) renamePath(backup, output) catch {};
 595     try renamePath(staging, output);
 596     if (has_backup) try deleteOutput(backup);
 597 }
 598 
 599 fn renamePath(from: []const u8, to: []const u8) !void {
 600     try std.Io.Dir.cwd().rename(from, std.Io.Dir.cwd(), to, io);
 601 }
 602 
 603 fn plannedOutputRoot(allocator: Allocator, output: []const u8) ![]u8 {
 604     if (pathExists(output)) return try realPathOwned(allocator, output);
 605     if (std.fs.path.isAbsolute(output)) return try allocator.dupe(u8, output);
 606     const cwd = try realPathOwned(allocator, ".");
 607     defer allocator.free(cwd);
 608     return try std.fs.path.join(allocator, &.{ cwd, output });
 609 }
 610 
 611 fn realPathOwned(allocator: Allocator, path: []const u8) ![]u8 {
 612     const real = try std.Io.Dir.cwd().realPathFileAlloc(io, path, allocator);
 613     defer allocator.free(real);
 614     return try allocator.dupe(u8, real);
 615 }
 616 
 617 fn buildPage(
 618     allocator: Allocator,
 619     options: BuildOptions,
 620     source_dir: *std.Io.Dir,
 621     output_dir: *std.Io.Dir,
 622     page_storage: *site_page.Storage,
 623     relative_path: []const u8,
 624     page_paths: site_path.Paths,
 625     catalog: site_catalog.Catalog,
 626     result: *BuildResult,
 627 ) !void {
 628     const bytes = try page_storage.readFile(source_dir.*, io, relative_path);
 629     defer page_storage.release();
 630     result.bytes_read += bytes.len;
 631 
 632     var frontmatter_parse = try FrontmatterParse.init(allocator, bytes, options.frontmatter_limits);
 633     defer frontmatter_parse.deinit(allocator);
 634     const parsed = frontmatter_parse.parsed;
 635     if (parsed.metadata.draft() and !options.include_drafts) {
 636         result.drafts += 1;
 637         return;
 638     }
 639     if (!parsed.metadata.curated() and !options.include_slop) {
 640         result.slop += 1;
 641         return;
 642     }
 643     const expanded = try expandPageLists(allocator, parsed.body, catalog);
 644     defer if (expanded) |source| allocator.free(source);
 645     const catalog_entry = catalog.findSource(relative_path) orelse unreachable;
 646     const mode = renderModeForDispatch(catalog_entry.dispatch);
 647     var rendered = try markdown.render(allocator, expanded orelse parsed.body, .{
 648         .mode = mode,
 649         .one_call_map_style_per_page = options.one_call_map_style_per_page,
 650         .diagnostic = options.diagnostic,
 651         .heading_limits = options.heading_limits,
 652         .footnote_limits = options.footnote_limits,
 653         .quiz_limits = options.quiz_limits,
 654         .slides = .{ .badge = if (uncuratedSlides(mode, parsed.metadata)) uncurated_badge else null },
 655     });
 656     defer rendered.deinit(allocator);
 657     const content = if (mode == .document and !parsed.metadata.curated())
 658         try std.mem.concat(allocator, u8, &.{ page_badge, rendered.html })
 659     else
 660         rendered.html;
 661     defer if (content.ptr != rendered.html.ptr) allocator.free(content);
 662 
 663     if (std.fs.path.dirname(page_paths.target)) |parent| try output_dir.createDirPath(io, parent);
 664     const title = parsed.metadata.title() orelse
 665         (if (rendered.document.firstHeading()) |heading| heading.text else "Untitled");
 666     const theme_page = theme.Page{
 667         .title = title,
 668         .description = parsed.metadata.description() orelse "",
 669         .content = content,
 670         .path = page_paths.public,
 671         .metadata = parsed.metadata,
 672     };
 673     const theme_plan = try theme.RenderPlan.inspect(
 674         options.theme,
 675         theme_page,
 676         options.theme_render_limits,
 677     );
 678     var theme_storage = try theme.RenderStorage.init(allocator, theme_plan.exactLimits());
 679     defer theme_storage.deinit(allocator);
 680     theme_storage.activate();
 681     const page = try theme.renderPage(&theme_storage, options.theme, theme_page);
 682     defer theme_storage.reset();
 683     const rebased = if (options.base.len == 0)
 684         page
 685     else
 686         try rebasePage(allocator, page, options.base, options.parent_site_base);
 687     defer if (rebased.ptr != page.ptr) allocator.free(rebased);
 688     try output_dir.writeFile(io, .{ .sub_path = page_paths.target, .data = rebased, .flags = .{ .truncate = true } });
 689     result.pages += 1;
 690     result.bytes_written += rebased.len;
 691 }
 692 
 693 const uncurated_badge = "SLOP";
 694 const page_badge = "<span class=\"zen-page-badge\" aria-label=\"Non-curated content\">SLOP</span>\n";
 695 
 696 fn uncuratedSlides(mode: markdown.Mode, metadata: frontmatter.Metadata) bool {
 697     return mode == .slides and !metadata.curated();
 698 }
 699 
 700 fn dispatchForRenderMode(mode: markdown.Mode) site_catalog.Dispatch {
 701     return switch (mode) {
 702         .document => .document,
 703         .slides => .presentation,
 704     };
 705 }
 706 
 707 fn renderModeForDispatch(dispatch: site_catalog.Dispatch) markdown.Mode {
 708     return switch (dispatch) {
 709         .document => .document,
 710         .presentation => .slides,
 711     };
 712 }
 713 
 714 const url_attribute_prefixes = [_][]const u8{ "href=\"", "href='", "src=\"", "src='" };
 715 
 716 fn rebasePage(
 717     allocator: Allocator,
 718     html: []const u8,
 719     base: []const u8,
 720     parent_site_base: ?[]const u8,
 721 ) Allocator.Error![]u8 {
 722     var out: std.ArrayList(u8) = .empty;
 723     errdefer out.deinit(allocator);
 724     var index: usize = 0;
 725     while (index < html.len) {
 726         const prefix = matchUrlAttribute(html[index..]) orelse {
 727             try out.append(allocator, html[index]);
 728             index += 1;
 729             continue;
 730         };
 731         try out.appendSlice(allocator, prefix);
 732         index += prefix.len;
 733         if (rootRelativeAt(html, index) and
 734             !parentSiteOwnsReferenceAt(html, index, parent_site_base))
 735         {
 736             try out.appendSlice(allocator, base);
 737         }
 738     }
 739     return try out.toOwnedSlice(allocator);
 740 }
 741 
 742 fn matchUrlAttribute(rest: []const u8) ?[]const u8 {
 743     for (url_attribute_prefixes) |prefix| {
 744         if (std.mem.startsWith(u8, rest, prefix)) return prefix;
 745     }
 746     return null;
 747 }
 748 
 749 fn rootRelativeAt(html: []const u8, index: usize) bool {
 750     if (index >= html.len or html[index] != '/') return false;
 751     return index + 1 >= html.len or html[index + 1] != '/';
 752 }
 753 
 754 /// Reports whether a hosted parent owns a child base at an exact route boundary.
 755 pub fn parentSiteOwnsBase(parent_site_base: []const u8, base: []const u8) bool {
 756     if (parent_site_base.len <= 1 or parent_site_base[0] != '/' or
 757         parent_site_base[parent_site_base.len - 1] == '/') return false;
 758     if (base.len == 0) return true;
 759     return base.len > parent_site_base.len and
 760         std.mem.startsWith(u8, base, parent_site_base) and
 761         base[parent_site_base.len] == '/';
 762 }
 763 
 764 fn parentSiteOwnsReferenceAt(
 765     html: []const u8,
 766     index: usize,
 767     parent_site_base: ?[]const u8,
 768 ) bool {
 769     const parent = parent_site_base orelse return false;
 770     const reference = html[index..];
 771     if (!std.mem.startsWith(u8, reference, parent)) return false;
 772     if (reference.len == parent.len) return true;
 773     return switch (reference[parent.len]) {
 774         '/', '?', '#', '\'', '"' => true,
 775         else => false,
 776     };
 777 }
 778 
 779 fn copyAsset(
 780     allocator: Allocator,
 781     options: BuildOptions,
 782     source_dir: *std.Io.Dir,
 783     output_dir: *std.Io.Dir,
 784     relative_path: []const u8,
 785     result: *BuildResult,
 786 ) !void {
 787     const bytes = try source_dir.readFileAlloc(io, relative_path, allocator, .limited(options.asset_limit));
 788     defer allocator.free(bytes);
 789     result.bytes_read += bytes.len;
 790     if (std.fs.path.dirname(relative_path)) |parent| try output_dir.createDirPath(io, parent);
 791     try output_dir.writeFile(io, .{ .sub_path = relative_path, .data = bytes, .flags = .{ .truncate = true } });
 792     result.assets += 1;
 793     result.bytes_written += bytes.len;
 794 }
 795 
 796 fn deleteOutput(path: []const u8) !void {
 797     if (isDirectory(path)) {
 798         try std.Io.Dir.cwd().deleteTree(io, path);
 799     } else if (pathExists(path)) {
 800         try std.Io.Dir.cwd().deleteFile(io, path);
 801     }
 802 }
 803 
 804 fn markdownStem(base: []const u8) []const u8 {
 805     if (std.mem.endsWith(u8, base, ".markdown")) return base[0 .. base.len - ".markdown".len];
 806     if (std.mem.endsWith(u8, base, ".md")) return base[0 .. base.len - ".md".len];
 807     return base;
 808 }
 809 
 810 fn expandPageLists(
 811     allocator: Allocator,
 812     source: []const u8,
 813     catalog: site_catalog.Catalog,
 814 ) !?[]u8 {
 815     var out: std.ArrayList(u8) = .empty;
 816     errdefer out.deinit(allocator);
 817     var cursor: usize = 0;
 818     var changed = false;
 819     while (nextSourceLine(source, &cursor)) |line| {
 820         if (pageListOpening(line)) {
 821             changed = true;
 822             var directive: std.ArrayList(u8) = .empty;
 823             defer directive.deinit(allocator);
 824             var closed = false;
 825             while (nextSourceLine(source, &cursor)) |inner| {
 826                 if (pageListClosing(inner)) {
 827                     closed = true;
 828                     break;
 829                 }
 830                 try directive.appendSlice(allocator, inner);
 831                 try directive.append(allocator, '\n');
 832             }
 833             if (!closed) return error.UnclosedPageListDirective;
 834             try appendPageList(allocator, &out, catalog, try parsePageListOptions(directive.items));
 835         } else {
 836             try out.appendSlice(allocator, line);
 837             try out.append(allocator, '\n');
 838         }
 839     }
 840     if (!changed) {
 841         out.deinit(allocator);
 842         return null;
 843     }
 844     return try out.toOwnedSlice(allocator);
 845 }
 846 
 847 fn pageListOpening(line: []const u8) bool {
 848     const trimmed = std.mem.trim(u8, std.mem.trim(u8, line, "\r"), " \t");
 849     if (!std.mem.startsWith(u8, trimmed, "```")) return false;
 850     const ticks = fenceTickCount(trimmed);
 851     if (ticks < 3) return false;
 852     const language = firstWord(std.mem.trim(u8, trimmed[ticks..], " \t"));
 853     return std.mem.eql(u8, language, "zen-pages") or std.mem.eql(u8, language, "pages");
 854 }
 855 
 856 fn pageListClosing(line: []const u8) bool {
 857     const trimmed = std.mem.trim(u8, std.mem.trim(u8, line, "\r"), " \t");
 858     if (!std.mem.startsWith(u8, trimmed, "```")) return false;
 859     return fenceTickCount(trimmed) >= 3;
 860 }
 861 
 862 fn fenceTickCount(line: []const u8) usize {
 863     var count: usize = 0;
 864     while (count < line.len and line[count] == '`') count += 1;
 865     return count;
 866 }
 867 
 868 fn firstWord(value: []const u8) []const u8 {
 869     var end: usize = 0;
 870     while (end < value.len and !std.ascii.isWhitespace(value[end])) end += 1;
 871     return value[0..end];
 872 }
 873 
 874 fn parsePageListOptions(source: []const u8) !PageListOptions {
 875     var options: PageListOptions = .{};
 876     var cursor: usize = 0;
 877     while (nextSourceLine(source, &cursor)) |raw_line| {
 878         const line = std.mem.trim(u8, std.mem.trim(u8, raw_line, "\r"), " \t");
 879         if (line.len == 0) continue;
 880         const split = std.mem.indexOfScalar(u8, line, ':') orelse return error.InvalidPageListDirective;
 881         const key = std.mem.trim(u8, line[0..split], " \t");
 882         const value = std.mem.trim(u8, line[split + 1 ..], " \t");
 883         if (std.mem.eql(u8, key, "section") or std.mem.eql(u8, key, "prefix")) {
 884             options.prefix = value;
 885         } else if (std.mem.eql(u8, key, "title")) {
 886             options.title = value;
 887         } else if (std.mem.eql(u8, key, "include-index")) {
 888             options.include_index = try parseBool(value);
 889         } else if (std.mem.eql(u8, key, "curated")) {
 890             options.curated = if (std.mem.eql(u8, value, "only"))
 891                 .only
 892             else if (std.mem.eql(u8, value, "exclude"))
 893                 .exclude
 894             else if (std.mem.eql(u8, value, "all"))
 895                 .all
 896             else
 897                 return error.InvalidPageListDirective;
 898         } else if (std.mem.eql(u8, key, "types")) {
 899             options.types = try parseBool(value);
 900         } else if (std.mem.eql(u8, key, "format")) {
 901             options.format = if (std.mem.eql(u8, value, "list"))
 902                 .list
 903             else if (std.mem.eql(u8, value, "table"))
 904                 .table
 905             else
 906                 return error.InvalidPageListDirective;
 907         } else {
 908             return error.InvalidPageListDirective;
 909         }
 910     }
 911     return options;
 912 }
 913 
 914 fn parseBool(value: []const u8) !bool {
 915     if (std.mem.eql(u8, value, "true")) return true;
 916     if (std.mem.eql(u8, value, "false")) return false;
 917     return error.InvalidPageListDirective;
 918 }
 919 
 920 fn appendPageList(
 921     allocator: Allocator,
 922     out: *std.ArrayList(u8),
 923     catalog: site_catalog.Catalog,
 924     options: PageListOptions,
 925 ) !void {
 926     var count: usize = 0;
 927     for (catalog.pages) |page| {
 928         if (pageListIncludes(page, options)) count += 1;
 929     }
 930     if (count == 0) return;
 931     if (options.format == .table and count > markdown.max_table_body_rows) {
 932         return error.PageListTableRowLimitExceeded;
 933     }
 934     if (options.title.len != 0) {
 935         try out.appendSlice(allocator, "## ");
 936         try out.appendSlice(allocator, options.title);
 937         try out.appendSlice(allocator, "\n\n");
 938     }
 939     switch (options.format) {
 940         .list => try appendPageListRows(allocator, out, catalog, options),
 941         .table => try appendPageTableRows(allocator, out, catalog, options),
 942     }
 943 }
 944 
 945 fn appendPageListRows(
 946     allocator: Allocator,
 947     out: *std.ArrayList(u8),
 948     catalog: site_catalog.Catalog,
 949     options: PageListOptions,
 950 ) !void {
 951     for (catalog.pages) |page| {
 952         if (!pageListIncludes(page, options)) continue;
 953         try out.appendSlice(allocator, "- ");
 954         if (page.date.len != 0) {
 955             try out.append(allocator, '`');
 956             try out.appendSlice(allocator, page.date);
 957             try out.appendSlice(allocator, "` ");
 958         } else if (page.draft) {
 959             try out.appendSlice(allocator, "`draft` ");
 960         }
 961         if (options.types and page.section.len != 0) {
 962             try out.append(allocator, '`');
 963             try out.appendSlice(
 964                 allocator,
 965                 if (page.type_label.len != 0) page.type_label else page.section,
 966             );
 967             try out.appendSlice(allocator, "` ");
 968         }
 969         try out.append(allocator, '[');
 970         try appendMarkdownLinkText(allocator, out, page.title);
 971         try out.appendSlice(allocator, "](");
 972         try out.appendSlice(allocator, page.url);
 973         try out.append(allocator, ')');
 974         if (page.date.len != 0 and page.draft) try out.appendSlice(allocator, " `draft`");
 975         if (page.slop and options.curated == .all) try out.appendSlice(allocator, " `slop`");
 976         try out.append(allocator, '\n');
 977     }
 978     try out.append(allocator, '\n');
 979 }
 980 
 981 fn appendPageTableRows(
 982     allocator: Allocator,
 983     out: *std.ArrayList(u8),
 984     catalog: site_catalog.Catalog,
 985     options: PageListOptions,
 986 ) !void {
 987     if (options.types) {
 988         try out.appendSlice(
 989             allocator,
 990             "| Content | Date | Tags |\n| :--- | :--- | :--- |\n",
 991         );
 992     } else {
 993         try out.appendSlice(allocator, "| Content | Date |\n| :--- | :--- |\n");
 994     }
 995     for (catalog.pages) |page| {
 996         if (!pageListIncludes(page, options)) continue;
 997         try out.appendSlice(allocator, "| ");
 998         try out.append(allocator, '[');
 999         try appendMarkdownTableLinkText(allocator, out, page.title);
1000         try out.appendSlice(allocator, "](");
1001         try out.appendSlice(allocator, page.url);
1002         try out.append(allocator, ')');
1003         if (page.draft) try out.appendSlice(allocator, " `draft`");
1004         if (page.slop and options.curated == .all) {
1005             try out.appendSlice(allocator, " `slop`");
1006         }
1007         try out.appendSlice(allocator, " | ");
1008         if (page.date.len != 0) {
1009             try appendCodeSpan(allocator, out, page.date);
1010         } else {
1011             try out.appendSlice(allocator, "—");
1012         }
1013         if (options.types) {
1014             try out.appendSlice(allocator, " | ");
1015             try appendPageTags(allocator, out, page);
1016         }
1017         try out.appendSlice(allocator, " |\n");
1018     }
1019     try out.append(allocator, '\n');
1020 }
1021 
1022 fn appendPageTags(
1023     allocator: Allocator,
1024     out: *std.ArrayList(u8),
1025     page: site_catalog.Entry,
1026 ) !void {
1027     var count: usize = 0;
1028     if (page.type_label.len != 0) {
1029         try appendCodeSpan(allocator, out, page.type_label);
1030         count += 1;
1031     }
1032     if (page.section.len != 0 and
1033         !std.mem.eql(u8, page.section, page.type_label))
1034     {
1035         if (count != 0) try out.append(allocator, ' ');
1036         try appendCodeSpan(allocator, out, page.section);
1037         count += 1;
1038     }
1039     if (count == 0) try out.appendSlice(allocator, "—");
1040 }
1041 
1042 fn appendCodeSpan(
1043     allocator: Allocator,
1044     out: *std.ArrayList(u8),
1045     value: []const u8,
1046 ) Allocator.Error!void {
1047     try out.append(allocator, '`');
1048     try out.appendSlice(allocator, value);
1049     try out.append(allocator, '`');
1050 }
1051 
1052 fn appendMarkdownLinkText(allocator: Allocator, out: *std.ArrayList(u8), value: []const u8) Allocator.Error!void {
1053     for (value) |byte| {
1054         if (byte == '\\' or byte == '[' or byte == ']') try out.append(allocator, '\\');
1055         try out.append(allocator, byte);
1056     }
1057 }
1058 
1059 fn appendMarkdownTableLinkText(
1060     allocator: Allocator,
1061     out: *std.ArrayList(u8),
1062     value: []const u8,
1063 ) Allocator.Error!void {
1064     for (value) |byte| {
1065         if (byte == '\\' or byte == '[' or byte == ']' or byte == '|') {
1066             try out.append(allocator, '\\');
1067         }
1068         try out.append(allocator, byte);
1069     }
1070 }
1071 
1072 fn pageListIncludes(page: site_catalog.Entry, options: PageListOptions) bool {
1073     if (!options.include_index and indexMarkdownPath(page.relative_path)) return false;
1074     switch (options.curated) {
1075         .all => {},
1076         .only => if (page.slop) return false,
1077         .exclude => if (!page.slop) return false,
1078     }
1079     if (options.prefix.len == 0) return true;
1080     if (!std.mem.startsWith(u8, page.relative_path, options.prefix)) return false;
1081     return page.relative_path.len == options.prefix.len or page.relative_path[options.prefix.len] == '/';
1082 }
1083 
1084 fn indexMarkdownPath(path: []const u8) bool {
1085     return std.mem.eql(u8, markdownStem(std.fs.path.basename(path)), "index");
1086 }
1087 
1088 fn nextSourceLine(source: []const u8, cursor: *usize) ?[]const u8 {
1089     if (cursor.* >= source.len) return null;
1090     const start = cursor.*;
1091     if (std.mem.indexOfScalarPos(u8, source, start, '\n')) |end| {
1092         cursor.* = end + 1;
1093         return source[start..end];
1094     }
1095     cursor.* = source.len;
1096     return source[start..];
1097 }
1098 
1099 fn isMarkdown(path: []const u8) bool {
1100     return std.mem.endsWith(u8, path, ".md") or std.mem.endsWith(u8, path, ".markdown");
1101 }
1102 
1103 fn includedSourcePath(options: BuildOptions, path: []const u8) bool {
1104     if (theme.ownsPath(path, options.theme_files)) return false;
1105     if (options.publish_authored_routes and
1106         std.mem.eql(u8, path, site_catalog.manifest_path)) return false;
1107     if (!options.copy_hidden and hiddenPath(path)) return false;
1108     if (excludedPath(path, options.exclude)) return false;
1109     if (options.skip_readme and std.mem.eql(u8, std.fs.path.basename(path), "README.md")) return false;
1110     return true;
1111 }
1112 
1113 fn hiddenPath(path: []const u8) bool {
1114     var parts = std.mem.splitScalar(u8, path, '/');
1115     while (parts.next()) |part| {
1116         if (part.len != 0 and part[0] == '.') return true;
1117     }
1118     return false;
1119 }
1120 
1121 fn excludedPath(path: []const u8, prefixes: []const []const u8) bool {
1122     for (prefixes) |prefix| {
1123         if (prefix.len == 0) continue;
1124         if (std.mem.eql(u8, path, prefix)) return true;
1125         if (path.len > prefix.len and
1126             std.mem.startsWith(u8, path, prefix) and
1127             path[prefix.len] == '/') return true;
1128     }
1129     return false;
1130 }
1131 
1132 fn pathExists(path: []const u8) bool {
1133     std.Io.Dir.cwd().access(io, path, .{}) catch return false;
1134     return true;
1135 }
1136 
1137 fn pathExistsAt(directory: *std.Io.Dir, path_value: []const u8) bool {
1138     directory.access(io, path_value, .{}) catch return false;
1139     return true;
1140 }
1141 
1142 fn isDirectory(path: []const u8) bool {
1143     var dir = std.Io.Dir.cwd().openDir(io, path, .{}) catch return false;
1144     dir.close(io);
1145     return true;
1146 }
1147 
1148 fn insideRoot(root: []const u8, target: []const u8) bool {
1149     if (std.mem.eql(u8, root, target)) return true;
1150     return target.len > root.len and
1151         std.mem.startsWith(u8, target, root) and
1152         std.fs.path.isSep(target[root.len]);
1153 }