lib/zen/src/site/test.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

   1 const std = @import("std");
   2 const diagnostic = @import("../root.zig").diagnostic;
   3 const subject = @import("root.zig");
   4 
   5 const site_page = subject.page;
   6 const site_path = subject.path;
   7 const io = std.Options.debug_io;
   8 const max_asset_bytes = subject.max_asset_bytes;
   9 const max_table_body_rows = @import("../root.zig").markdown.max_table_body_rows;
  10 const buildSite = subject.buildSite;
  11 
  12 fn stagingPath(allocator: std.mem.Allocator, output: []const u8) std.mem.Allocator.Error![]u8 {
  13     return try std.fmt.allocPrint(allocator, "{s}.zen-tmp", .{output});
  14 }
  15 
  16 fn backupPath(allocator: std.mem.Allocator, output: []const u8) std.mem.Allocator.Error![]u8 {
  17     return try std.fmt.allocPrint(allocator, "{s}.zen-old", .{output});
  18 }
  19 
  20 fn pathExists(path: []const u8) bool {
  21     std.Io.Dir.cwd().access(io, path, .{}) catch return false;
  22     return true;
  23 }
  24 
  25 const PublishHookFixture = struct {
  26     fail: bool,
  27     calls: usize = 0,
  28 
  29     fn hook(self: *PublishHookFixture) subject.BeforePublish {
  30         return .{ .context = self, .run = run };
  31     }
  32 
  33     fn run(
  34         context: *anyopaque,
  35         allocator: std.mem.Allocator,
  36         output: []const u8,
  37     ) !void {
  38         const self: *PublishHookFixture = @ptrCast(@alignCast(context));
  39         self.calls += 1;
  40         if (self.fail) return error.PublishRejected;
  41         const marker = try std.fs.path.join(allocator, &.{ output, "hook.txt" });
  42         defer allocator.free(marker);
  43         try std.Io.Dir.cwd().writeFile(io, .{
  44             .sub_path = marker,
  45             .data = "prepared",
  46         });
  47     }
  48 };
  49 
  50 test "site build reuses bounded relative and public paths" {
  51     comptime {
  52         @stardustClaim(
  53             @import("alloc_phase").capacity.witness(@import("./path/root.zig").Storage, "zen_site_path_consumer"),
  54             null,
  55             null,
  56             null,
  57             null,
  58             null,
  59             null,
  60         );
  61     }
  62 
  63     var tmp = std.testing.tmpDir(.{});
  64     defer tmp.cleanup();
  65     try tmp.dir.createDirPath(io, "site/docs");
  66     try tmp.dir.createDirPath(io, "site/assets");
  67     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "---\n" ++
  68         "title: Home\n" ++
  69         "description: Site <home>\n" ++
  70         "---\n" ++
  71         "# Welcome\n\n" ++
  72         "Read the [guide](/docs/guide/).\n" });
  73     try tmp.dir.writeFile(io, .{ .sub_path = "site/docs/guide.md", .data = "# Guide\n\n- fast\n- robust\n" });
  74     try tmp.dir.writeFile(io, .{ .sub_path = "site/assets/app.js", .data = "console.log('zen');" });
  75     try tmp.dir.writeFile(io, .{ .sub_path = "site/.secret", .data = "hidden" });
  76     try tmp.dir.createDirPath(io, "out");
  77     try tmp.dir.writeFile(io, .{ .sub_path = "out/stale.txt", .data = "stale" });
  78 
  79     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
  80     defer std.testing.allocator.free(source);
  81     const output = try tmp.dir.realPathFileAlloc(io, "out", std.testing.allocator);
  82     defer std.testing.allocator.free(output);
  83     const result = try buildSite(std.testing.allocator, .{
  84         .source = source,
  85         .output = output,
  86         .theme = .{
  87             .site_title = "Example",
  88             .layout = "<title>{{title}}/{{site.title}}</title><meta content=\"{{description}}\"><body data-path=\"{{path}}\">{{content}}</body>",
  89             .style = "body{color:black}\n",
  90         },
  91     });
  92 
  93     try std.testing.expectEqual(@as(usize, 2), result.pages);
  94     try std.testing.expectEqual(@as(usize, 2), result.assets);
  95 
  96     const index_path = try std.fs.path.join(std.testing.allocator, &.{ output, "index.html" });
  97     defer std.testing.allocator.free(index_path);
  98     const guide_path = try std.fs.path.join(std.testing.allocator, &.{ output, "docs", "guide", "index.html" });
  99     defer std.testing.allocator.free(guide_path);
 100     const asset_path = try std.fs.path.join(std.testing.allocator, &.{ output, "assets", "app.js" });
 101     defer std.testing.allocator.free(asset_path);
 102     const stale_path = try std.fs.path.join(std.testing.allocator, &.{ output, "stale.txt" });
 103     defer std.testing.allocator.free(stale_path);
 104     const hidden_path = try std.fs.path.join(std.testing.allocator, &.{ output, ".secret" });
 105     defer std.testing.allocator.free(hidden_path);
 106     const style_path = try std.fs.path.join(std.testing.allocator, &.{ output, "zen.css" });
 107     defer std.testing.allocator.free(style_path);
 108     const route_manifest_path = try std.fs.path.join(
 109         std.testing.allocator,
 110         &.{ output, subject.authored_route_manifest_path },
 111     );
 112     defer std.testing.allocator.free(route_manifest_path);
 113 
 114     const index = try std.Io.Dir.cwd().readFileAlloc(io, index_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
 115     defer std.testing.allocator.free(index);
 116     const guide = try std.Io.Dir.cwd().readFileAlloc(io, guide_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
 117     defer std.testing.allocator.free(guide);
 118     const asset = try std.Io.Dir.cwd().readFileAlloc(io, asset_path, std.testing.allocator, .limited(max_asset_bytes));
 119     defer std.testing.allocator.free(asset);
 120     const style = try std.Io.Dir.cwd().readFileAlloc(io, style_path, std.testing.allocator, .limited(max_asset_bytes));
 121     defer std.testing.allocator.free(style);
 122 
 123     try std.testing.expect(std.mem.indexOf(u8, index, "<title>Home/Example</title>") != null);
 124     try std.testing.expect(std.mem.indexOf(u8, index, "<meta content=\"Site &lt;home&gt;\">") != null);
 125     try std.testing.expect(std.mem.indexOf(u8, index, "<h1 id=\"welcome\">Welcome</h1>") != null);
 126     try std.testing.expect(std.mem.indexOf(u8, guide, "<li>fast</li>") != null);
 127     try std.testing.expectEqualStrings("console.log('zen');", asset);
 128     try std.testing.expectEqualStrings("body{color:black}\n", style);
 129     try std.testing.expect(!pathExists(stale_path));
 130     try std.testing.expect(!pathExists(hidden_path));
 131     try std.testing.expect(!pathExists(route_manifest_path));
 132 
 133     const staging = try stagingPath(std.testing.allocator, output);
 134     defer std.testing.allocator.free(staging);
 135     const backup = try backupPath(std.testing.allocator, output);
 136     defer std.testing.allocator.free(backup);
 137     try std.testing.expect(!pathExists(staging));
 138     try std.testing.expect(!pathExists(backup));
 139 }
 140 
 141 test "site build publishes the opt-in authored route manifest atomically" {
 142     var tmp = std.testing.tmpDir(.{});
 143     defer tmp.cleanup();
 144     try tmp.dir.createDirPath(io, "site/presentations");
 145     try tmp.dir.writeFile(io, .{
 146         .sub_path = "site/index.md",
 147         .data = "---\ntitle: Home\n---\n# Home\n",
 148     });
 149     try tmp.dir.writeFile(io, .{
 150         .sub_path = "site/presentations/talk.md",
 151         .data = "---\ntitle: Talk\nrender: slides\n---\n# Talk\n",
 152     });
 153     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 154     defer std.testing.allocator.free(source);
 155     const root = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 156     defer std.testing.allocator.free(root);
 157     const output = try std.fs.path.join(std.testing.allocator, &.{ root, "out" });
 158     defer std.testing.allocator.free(output);
 159 
 160     _ = try buildSite(std.testing.allocator, .{
 161         .source = source,
 162         .output = output,
 163         .base = "/tiny",
 164         .publish_authored_routes = true,
 165     });
 166     const manifest_path = try std.fs.path.join(
 167         std.testing.allocator,
 168         &.{ output, subject.authored_route_manifest_path },
 169     );
 170     defer std.testing.allocator.free(manifest_path);
 171     const manifest = try std.Io.Dir.cwd().readFileAlloc(
 172         io,
 173         manifest_path,
 174         std.testing.allocator,
 175         .limited(max_asset_bytes),
 176     );
 177     defer std.testing.allocator.free(manifest);
 178     const expected = "{\"version\":1,\"routes\":[" ++
 179         "{\"route\":\"/tiny/\",\"source\":\"index.md\",\"dispatch\":\"document\",\"title\":\"Home\",\"draft\":false,\"slop\":true}," ++
 180         "{\"route\":\"/tiny/presentations/talk/\",\"source\":\"presentations/talk.md\",\"dispatch\":\"presentation\",\"title\":\"Talk\",\"draft\":false,\"slop\":true}]}\n";
 181     try std.testing.expectEqualStrings(expected, manifest);
 182 
 183     try tmp.dir.writeFile(io, .{
 184         .sub_path = "site/index.md",
 185         .data = "---\ntitle: Broken\nrender: slide\n---\n# Broken\n",
 186     });
 187     try std.testing.expectError(error.InvalidRenderMode, buildSite(
 188         std.testing.allocator,
 189         .{
 190             .source = source,
 191             .output = output,
 192             .base = "/tiny",
 193             .publish_authored_routes = true,
 194         },
 195     ));
 196     const preserved = try std.Io.Dir.cwd().readFileAlloc(
 197         io,
 198         manifest_path,
 199         std.testing.allocator,
 200         .limited(max_asset_bytes),
 201     );
 202     defer std.testing.allocator.free(preserved);
 203     try std.testing.expectEqualStrings(expected, preserved);
 204 
 205     try std.testing.expectError(error.AuthoredRouteManifestRequiresClean, buildSite(
 206         std.testing.allocator,
 207         .{
 208             .source = source,
 209             .output = output,
 210             .clean = false,
 211             .publish_authored_routes = true,
 212         },
 213     ));
 214     try std.testing.expectError(error.AuthoredRouteManifestPathConflict, buildSite(
 215         std.testing.allocator,
 216         .{
 217             .source = source,
 218             .output = output,
 219             .theme = .{ .style_path = subject.authored_route_manifest_path },
 220             .publish_authored_routes = true,
 221         },
 222     ));
 223 }
 224 
 225 test "site build publishes stable explicit heading anchors" {
 226     var tmp = std.testing.tmpDir(.{});
 227     defer tmp.cleanup();
 228     try tmp.dir.createDirPath(io, "site");
 229     try tmp.dir.createDirPath(io, "out");
 230     try tmp.dir.writeFile(io, .{
 231         .sub_path = "site/index.md",
 232         .data = "```zen-toc\n" ++
 233             "scope: document\n" ++
 234             "```\n\n" ++
 235             "# Render\n\n" ++
 236             "## Public render {#render}\n\n" ++
 237             "### Options {#tiny.zen.RenderOptions}\n",
 238     });
 239     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 240     defer std.testing.allocator.free(source);
 241     const output = try tmp.dir.realPathFileAlloc(io, "out", std.testing.allocator);
 242     defer std.testing.allocator.free(output);
 243     _ = try buildSite(std.testing.allocator, .{
 244         .source = source,
 245         .output = output,
 246     });
 247     const index_path = try std.fs.path.join(
 248         std.testing.allocator,
 249         &.{ output, "index.html" },
 250     );
 251     defer std.testing.allocator.free(index_path);
 252     const index = try std.Io.Dir.cwd().readFileAlloc(
 253         io,
 254         index_path,
 255         std.testing.allocator,
 256         .limited(site_page.default_limits.max_page_bytes),
 257     );
 258     defer std.testing.allocator.free(index);
 259     try std.testing.expect(std.mem.indexOf(u8, index, "<h1 id=\"render~1\">Render</h1>") != null);
 260     try std.testing.expect(std.mem.indexOf(u8, index, "<a href=\"#render\">Public render</a>") != null);
 261     try std.testing.expect(std.mem.indexOf(u8, index, "<h3 id=\"tiny.zen.RenderOptions\">Options</h3>") != null);
 262     try std.testing.expect(std.mem.indexOf(u8, index, "{#") == null);
 263 }
 264 
 265 test "site build owns page-local call-map stylesheet compaction" {
 266     var tmp = std.testing.tmpDir(.{});
 267     defer tmp.cleanup();
 268     try tmp.dir.createDirPath(io, "site");
 269     const call_map =
 270         "<figure class=\"docs-call-map docs-call-map-primary zen-role-wide\">" ++
 271         "<svg role=\"group\"><style>.node{fill:red}</style>" ++
 272         "<text class=\"node\">run</text></svg>" ++
 273         "<figcaption>Static calls.</figcaption></figure>";
 274     var source_storage: [2048]u8 = undefined;
 275     var source_writer = std.Io.Writer.fixed(&source_storage);
 276     for (0..2) |_| {
 277         try source_writer.writeAll("```zen-ego\n");
 278         try source_writer.writeAll(call_map);
 279         try source_writer.writeAll("\n```\n");
 280     }
 281     try tmp.dir.writeFile(io, .{
 282         .sub_path = "site/index.md",
 283         .data = source_writer.buffered(),
 284     });
 285     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 286     defer std.testing.allocator.free(source);
 287     const root = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 288     defer std.testing.allocator.free(root);
 289     const compact_output = try std.fs.path.join(
 290         std.testing.allocator,
 291         &.{ root, "compact" },
 292     );
 293     defer std.testing.allocator.free(compact_output);
 294     _ = try buildSite(std.testing.allocator, .{
 295         .source = source,
 296         .output = compact_output,
 297     });
 298     const compact_path = try std.fs.path.join(
 299         std.testing.allocator,
 300         &.{ compact_output, "index.html" },
 301     );
 302     defer std.testing.allocator.free(compact_path);
 303     const compact = try std.Io.Dir.cwd().readFileAlloc(
 304         io,
 305         compact_path,
 306         std.testing.allocator,
 307         .limited(site_page.default_limits.max_page_bytes),
 308     );
 309     defer std.testing.allocator.free(compact);
 310     try std.testing.expectEqual(
 311         @as(usize, 1),
 312         std.mem.count(u8, compact, "<style>"),
 313     );
 314 
 315     const exact_output = try std.fs.path.join(
 316         std.testing.allocator,
 317         &.{ root, "exact" },
 318     );
 319     defer std.testing.allocator.free(exact_output);
 320     _ = try buildSite(std.testing.allocator, .{
 321         .source = source,
 322         .output = exact_output,
 323         .one_call_map_style_per_page = false,
 324     });
 325     const exact_path = try std.fs.path.join(
 326         std.testing.allocator,
 327         &.{ exact_output, "index.html" },
 328     );
 329     defer std.testing.allocator.free(exact_path);
 330     const exact = try std.Io.Dir.cwd().readFileAlloc(
 331         io,
 332         exact_path,
 333         std.testing.allocator,
 334         .limited(site_page.default_limits.max_page_bytes),
 335     );
 336     defer std.testing.allocator.free(exact);
 337     try std.testing.expectEqual(
 338         @as(usize, 2),
 339         std.mem.count(u8, exact, "<style>"),
 340     );
 341 }
 342 
 343 test "site build skips excluded source prefixes" {
 344     var tmp = std.testing.tmpDir(.{});
 345     defer tmp.cleanup();
 346     try tmp.dir.createDirPath(io, "site/tools/gen");
 347     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "# Home\n" });
 348     try tmp.dir.writeFile(io, .{ .sub_path = "site/tools/gen/make.py", .data = "print('x')" });
 349     try tmp.dir.writeFile(io, .{ .sub_path = "site/tools/notes.md", .data = "# Notes\n" });
 350     try tmp.dir.writeFile(io, .{ .sub_path = "site/toolset.txt", .data = "kept" });
 351 
 352     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 353     defer std.testing.allocator.free(source);
 354     const output = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 355     defer std.testing.allocator.free(output);
 356     const out = try std.fs.path.join(std.testing.allocator, &.{ output, "out" });
 357     defer std.testing.allocator.free(out);
 358 
 359     const result = try buildSite(std.testing.allocator, .{
 360         .source = source,
 361         .output = out,
 362         .exclude = &.{"tools"},
 363     });
 364 
 365     try std.testing.expectEqual(@as(usize, 1), result.pages);
 366 
 367     const tools_out = try std.fs.path.join(std.testing.allocator, &.{ out, "tools" });
 368     defer std.testing.allocator.free(tools_out);
 369     const kept_path = try std.fs.path.join(std.testing.allocator, &.{ out, "toolset.txt" });
 370     defer std.testing.allocator.free(kept_path);
 371     try std.testing.expect(!pathExists(tools_out));
 372     try std.testing.expect(pathExists(kept_path));
 373 }
 374 
 375 test "site build exposes quiz limits" {
 376     var tmp = std.testing.tmpDir(.{});
 377     defer tmp.cleanup();
 378     try tmp.dir.createDirPath(io, "site");
 379     try tmp.dir.writeFile(io, .{
 380         .sub_path = "site/index.md",
 381         .data = "```quiz\n? Question\n! Answer\n```\n",
 382     });
 383     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 384     defer std.testing.allocator.free(source);
 385     const root = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 386     defer std.testing.allocator.free(root);
 387     const output = try std.fs.path.join(std.testing.allocator, &.{ root, "out" });
 388     defer std.testing.allocator.free(output);
 389 
 390     try std.testing.expectError(error.QuestionCapacityExceeded, buildSite(
 391         std.testing.allocator,
 392         .{
 393             .source = source,
 394             .output = output,
 395             .quiz_limits = .{
 396                 .max_questions = 0,
 397                 .max_options = 0,
 398                 .max_joined_text_bytes = 0,
 399             },
 400         },
 401     ));
 402 }
 403 
 404 test "site build exposes path limits" {
 405     var tmp = std.testing.tmpDir(.{});
 406     defer tmp.cleanup();
 407     try tmp.dir.createDirPath(io, "site");
 408     try tmp.dir.writeFile(io, .{
 409         .sub_path = "site/index.md",
 410         .data = "# Home\n",
 411     });
 412     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 413     defer std.testing.allocator.free(source);
 414     const root = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 415     defer std.testing.allocator.free(root);
 416     const output = try std.fs.path.join(std.testing.allocator, &.{ root, "out" });
 417     defer std.testing.allocator.free(output);
 418 
 419     try std.testing.expectError(error.SiteTargetPathCapacityExceeded, buildSite(
 420         std.testing.allocator,
 421         .{
 422             .source = source,
 423             .output = output,
 424             .path_limits = .{
 425                 .max_target_path_bytes = "index.html".len - 1,
 426                 .max_public_path_bytes = 1,
 427             },
 428         },
 429     ));
 430     try std.testing.expectError(error.SitePublicPathCapacityExceeded, buildSite(
 431         std.testing.allocator,
 432         .{
 433             .source = source,
 434             .output = output,
 435             .path_limits = .{
 436                 .max_target_path_bytes = "index.html".len,
 437                 .max_public_path_bytes = 0,
 438             },
 439         },
 440     ));
 441 }
 442 
 443 test "site build reuses surveyed Markdown page input" {
 444     comptime {
 445         @stardustClaim(
 446             @import("alloc_phase").capacity.witness(@import("./page/root.zig").Storage, "zen_site_page_consumer_transitive_risk"),
 447             null,
 448             null,
 449             null,
 450             null,
 451             null,
 452             null,
 453         );
 454     }
 455     comptime {
 456         @stardustClaim(
 457             @import("alloc_phase").capacity.witness(@import("./page/root.zig").Storage, "zen_site_page_consumer_foreign_risk"),
 458             null,
 459             null,
 460             null,
 461             null,
 462             null,
 463             null,
 464         );
 465     }
 466 
 467     var tmp = std.testing.tmpDir(.{});
 468     defer tmp.cleanup();
 469     try tmp.dir.createDirPath(io, "site");
 470     try tmp.dir.writeFile(io, .{
 471         .sub_path = "site/index.md",
 472         .data = "# A\n",
 473     });
 474     try tmp.dir.writeFile(io, .{
 475         .sub_path = "site/.ignored.md",
 476         .data = "# ignored\n",
 477     });
 478     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 479     defer std.testing.allocator.free(source);
 480     const root = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 481     defer std.testing.allocator.free(root);
 482     const output = try std.fs.path.join(std.testing.allocator, &.{ root, "out" });
 483     defer std.testing.allocator.free(output);
 484 
 485     const result = try buildSite(std.testing.allocator, .{
 486         .source = source,
 487         .output = output,
 488         .page_limits = .{ .max_page_bytes = 4 },
 489     });
 490     try std.testing.expectEqual(@as(usize, 1), result.pages);
 491 
 492     try tmp.dir.writeFile(io, .{
 493         .sub_path = "site/index.md",
 494         .data = "# AB\n",
 495     });
 496     var report: diagnostic.Diagnostic = .{};
 497     try std.testing.expectError(error.SitePageCapacityExceeded, buildSite(
 498         std.testing.allocator,
 499         .{
 500             .source = source,
 501             .output = output,
 502             .page_limits = .{ .max_page_bytes = 4 },
 503             .diagnostic = &report,
 504         },
 505     ));
 506     try std.testing.expectEqualStrings("index.md", report.page());
 507 }
 508 
 509 test "site build rejects catalog overload before rendering" {
 510     comptime {
 511         @stardustClaim(
 512             @import("alloc_phase").capacity.witness(@import("./catalog/root.zig").Storage, "zen_site_catalog_consumer"),
 513             null,
 514             null,
 515             null,
 516             null,
 517             null,
 518             null,
 519         );
 520     }
 521 
 522     var tmp = std.testing.tmpDir(.{});
 523     defer tmp.cleanup();
 524     try tmp.dir.createDirPath(io, "site");
 525     try tmp.dir.writeFile(io, .{
 526         .sub_path = "site/index.md",
 527         .data = "---\ntitle: Home\n---\n# Home\n",
 528     });
 529     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 530     defer std.testing.allocator.free(source);
 531     const root = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 532     defer std.testing.allocator.free(root);
 533     const output = try std.fs.path.join(std.testing.allocator, &.{ root, "out" });
 534     defer std.testing.allocator.free(output);
 535     const text_bytes = "index.md".len + "/".len + "Home".len;
 536 
 537     const exact = try buildSite(std.testing.allocator, .{
 538         .source = source,
 539         .output = output,
 540         .catalog_limits = .{ .max_pages = 1, .max_text_bytes = text_bytes },
 541     });
 542     try std.testing.expectEqual(@as(usize, 1), exact.pages);
 543 
 544     const text_output = try std.fs.path.join(std.testing.allocator, &.{ root, "text" });
 545     defer std.testing.allocator.free(text_output);
 546     var text_report: diagnostic.Diagnostic = .{};
 547     try std.testing.expectError(error.SiteCatalogTextCapacityExceeded, buildSite(
 548         std.testing.allocator,
 549         .{
 550             .source = source,
 551             .output = text_output,
 552             .catalog_limits = .{ .max_pages = 1, .max_text_bytes = text_bytes - 1 },
 553             .diagnostic = &text_report,
 554         },
 555     ));
 556     try std.testing.expectEqualStrings("index.md", text_report.page());
 557     try std.testing.expect(!pathExists(text_output));
 558 
 559     try tmp.dir.writeFile(io, .{ .sub_path = "site/next.md", .data = "# Next\n" });
 560     var page_report: diagnostic.Diagnostic = .{};
 561     try std.testing.expectError(error.SiteCatalogPageCapacityExceeded, buildSite(
 562         std.testing.allocator,
 563         .{
 564             .source = source,
 565             .output = output,
 566             .catalog_limits = .{ .max_pages = 1, .max_text_bytes = 1024 },
 567             .diagnostic = &page_report,
 568         },
 569     ));
 570     try std.testing.expect(page_report.page().len != 0);
 571 }
 572 
 573 test "site build preserves bounded flat page paths" {
 574     var tmp = std.testing.tmpDir(.{});
 575     defer tmp.cleanup();
 576     try tmp.dir.createDirPath(io, "site/docs");
 577     try tmp.dir.writeFile(io, .{
 578         .sub_path = "site/docs/guide.markdown",
 579         .data = "# Guide\n",
 580     });
 581     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 582     defer std.testing.allocator.free(source);
 583     const root = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 584     defer std.testing.allocator.free(root);
 585     const output = try std.fs.path.join(std.testing.allocator, &.{ root, "out" });
 586     defer std.testing.allocator.free(output);
 587     const plan = try site_path.Plan.inspect(
 588         "docs/guide.markdown",
 589         false,
 590         .target,
 591         site_path.default_limits,
 592     );
 593 
 594     const result = try buildSite(std.testing.allocator, .{
 595         .source = source,
 596         .output = output,
 597         .clean_urls = false,
 598         .path_limits = plan.exactLimits(),
 599         .theme = .{ .layout = "{{path}}|{{content}}" },
 600     });
 601     try std.testing.expectEqual(@as(usize, 1), result.pages);
 602     const page_path = try std.fs.path.join(
 603         std.testing.allocator,
 604         &.{ output, "docs", "guide.html" },
 605     );
 606     defer std.testing.allocator.free(page_path);
 607     const page = try std.Io.Dir.cwd().readFileAlloc(
 608         io,
 609         page_path,
 610         std.testing.allocator,
 611         .limited(site_page.default_limits.max_page_bytes),
 612     );
 613     defer std.testing.allocator.free(page);
 614     try std.testing.expect(std.mem.startsWith(u8, page, "/docs/guide.html|"));
 615 }
 616 
 617 test "site build exposes theme render limits" {
 618     var tmp = std.testing.tmpDir(.{});
 619     defer tmp.cleanup();
 620     try tmp.dir.createDirPath(io, "site");
 621     try tmp.dir.writeFile(io, .{
 622         .sub_path = "site/index.md",
 623         .data = "# Home\n",
 624     });
 625     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 626     defer std.testing.allocator.free(source);
 627     const root = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 628     defer std.testing.allocator.free(root);
 629     const output = try std.fs.path.join(std.testing.allocator, &.{ root, "out" });
 630     defer std.testing.allocator.free(output);
 631 
 632     try std.testing.expectError(error.ThemeOutputByteCapacityExceeded, buildSite(
 633         std.testing.allocator,
 634         .{
 635             .source = source,
 636             .output = output,
 637             .theme_render_limits = .{ .max_output_bytes = 0 },
 638         },
 639     ));
 640 }
 641 
 642 test "site build exposes heading limits" {
 643     var tmp = std.testing.tmpDir(.{});
 644     defer tmp.cleanup();
 645     try tmp.dir.createDirPath(io, "site");
 646     try tmp.dir.writeFile(io, .{
 647         .sub_path = "site/index.md",
 648         .data = "# Heading\n",
 649     });
 650     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 651     defer std.testing.allocator.free(source);
 652     const root = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 653     defer std.testing.allocator.free(root);
 654     const output = try std.fs.path.join(std.testing.allocator, &.{ root, "out" });
 655     defer std.testing.allocator.free(output);
 656 
 657     try std.testing.expectError(error.HeadingCapacityExceeded, buildSite(
 658         std.testing.allocator,
 659         .{
 660             .source = source,
 661             .output = output,
 662             .heading_limits = .{
 663                 .max_headings = 0,
 664                 .max_text_bytes = 0,
 665                 .max_id_bytes = 0,
 666             },
 667         },
 668     ));
 669 }
 670 
 671 test "site build exposes footnote limits" {
 672     var tmp = std.testing.tmpDir(.{});
 673     defer tmp.cleanup();
 674     try tmp.dir.createDirPath(io, "site");
 675     try tmp.dir.writeFile(io, .{
 676         .sub_path = "site/index.md",
 677         .data = "Reference[^one].\n\n[^one]: Definition.\n",
 678     });
 679     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 680     defer std.testing.allocator.free(source);
 681     const root = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 682     defer std.testing.allocator.free(root);
 683     const output = try std.fs.path.join(std.testing.allocator, &.{ root, "out" });
 684     defer std.testing.allocator.free(output);
 685 
 686     try std.testing.expectError(error.FootnoteDefinitionCapacityExceeded, buildSite(
 687         std.testing.allocator,
 688         .{
 689             .source = source,
 690             .output = output,
 691             .footnote_limits = .{
 692                 .max_definitions = 0,
 693                 .max_joined_text_bytes = 0,
 694             },
 695         },
 696     ));
 697 }
 698 
 699 test "site build renders frontmatter slide decks" {
 700     var tmp = std.testing.tmpDir(.{});
 701     defer tmp.cleanup();
 702     try tmp.dir.createDirPath(io, "site/assets");
 703     try tmp.dir.writeFile(io, .{ .sub_path = "site/assets/plot.svg", .data = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 1 1\"></svg>\n" });
 704     try tmp.dir.writeFile(io, .{ .sub_path = "site/deck.md", .data = "---\n" ++
 705         "title: Slides\n" ++
 706         "render: slides\n" ++
 707         "---\n" ++
 708         "# Slides\n\n" ++
 709         "![Plot](/assets/plot.svg)\n\n" ++
 710         "---\n\n" ++
 711         "## Formula\n\n" ++
 712         "$$ E = mc^2 $$\n" });
 713 
 714     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 715     defer std.testing.allocator.free(source);
 716     const output = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 717     defer std.testing.allocator.free(output);
 718     const out = try std.fs.path.join(std.testing.allocator, &.{ output, "out" });
 719     defer std.testing.allocator.free(out);
 720 
 721     const result = try buildSite(std.testing.allocator, .{
 722         .source = source,
 723         .output = out,
 724     });
 725 
 726     try std.testing.expectEqual(@as(usize, 1), result.pages);
 727     try std.testing.expectEqual(@as(usize, 2), result.assets);
 728 
 729     const deck_path = try std.fs.path.join(std.testing.allocator, &.{ out, "deck", "index.html" });
 730     defer std.testing.allocator.free(deck_path);
 731     const style_path = try std.fs.path.join(std.testing.allocator, &.{ out, "zen.css" });
 732     defer std.testing.allocator.free(style_path);
 733     const deck = try std.Io.Dir.cwd().readFileAlloc(io, deck_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
 734     defer std.testing.allocator.free(deck);
 735     const style = try std.Io.Dir.cwd().readFileAlloc(io, style_path, std.testing.allocator, .limited(max_asset_bytes));
 736     defer std.testing.allocator.free(style);
 737 
 738     try std.testing.expect(std.mem.indexOf(u8, deck, "<div class=\"zen-slides\" data-zen-slides>") != null);
 739     try std.testing.expect(std.mem.indexOf(u8, deck, "<section class=\"zen-slide\" id=\"slide-2\"") != null);
 740     try std.testing.expect(std.mem.indexOf(u8, deck, "<div class=\"zen-slide-frame\">") != null);
 741     try std.testing.expect(std.mem.indexOf(u8, deck, "<span class=\"zen-slide-badge\" aria-label=\"Non-curated content\">SLOP</span>") != null);
 742     try std.testing.expect(std.mem.indexOf(u8, deck, "<img src=\"/assets/plot.svg\" alt=\"Plot\">") != null);
 743     try std.testing.expect(std.mem.indexOf(u8, deck, "<math display=\"block\">") != null);
 744     try std.testing.expect(std.mem.indexOf(u8, deck, "<hr>") == null);
 745     try std.testing.expect(std.mem.indexOf(u8, style, ".zen-slide img") != null);
 746     try std.testing.expect(std.mem.indexOf(u8, style, ".zen-slide-frame") != null);
 747 }
 748 
 749 test "site build loads source theme files and skips theme assets" {
 750     var tmp = std.testing.tmpDir(.{});
 751     defer tmp.cleanup();
 752     try tmp.dir.createDirPath(io, "site/.zen");
 753     try tmp.dir.createDirPath(io, "site/.zen/includes");
 754     try tmp.dir.writeFile(io, .{ .sub_path = "site/.zen/layout.html", .data = "<html lang=\"{{language}}\"><title>{{title}}/{{site.title}}</title><link href=\"/{{style.path}}\">{{include.header}}<main>{{content}}</main></html>" });
 755     try tmp.dir.writeFile(io, .{ .sub_path = "site/.zen/style.css", .data = "main{max-width:70ch}\n" });
 756     try tmp.dir.writeFile(io, .{ .sub_path = "site/.zen/includes/header.html", .data = "<header>Docs Header</header>" });
 757     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "---\ntitle: Themed\n---\n# Hello\n" });
 758 
 759     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 760     defer std.testing.allocator.free(source);
 761     const output = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 762     defer std.testing.allocator.free(output);
 763     const out = try std.fs.path.join(std.testing.allocator, &.{ output, "out" });
 764     defer std.testing.allocator.free(out);
 765 
 766     const result = try buildSite(std.testing.allocator, .{
 767         .source = source,
 768         .output = out,
 769         .theme = .{ .site_title = "Docs" },
 770         .copy_hidden = true,
 771     });
 772 
 773     try std.testing.expectEqual(@as(usize, 1), result.pages);
 774     try std.testing.expectEqual(@as(usize, 1), result.assets);
 775 
 776     const index_path = try std.fs.path.join(std.testing.allocator, &.{ out, "index.html" });
 777     defer std.testing.allocator.free(index_path);
 778     const style_path = try std.fs.path.join(std.testing.allocator, &.{ out, "zen.css" });
 779     defer std.testing.allocator.free(style_path);
 780     const layout_asset_path = try std.fs.path.join(std.testing.allocator, &.{ out, ".zen", "layout.html" });
 781     defer std.testing.allocator.free(layout_asset_path);
 782     const style_asset_path = try std.fs.path.join(std.testing.allocator, &.{ out, ".zen", "style.css" });
 783     defer std.testing.allocator.free(style_asset_path);
 784     const include_asset_path = try std.fs.path.join(std.testing.allocator, &.{ out, ".zen", "includes", "header.html" });
 785     defer std.testing.allocator.free(include_asset_path);
 786 
 787     const index = try std.Io.Dir.cwd().readFileAlloc(io, index_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
 788     defer std.testing.allocator.free(index);
 789     const style = try std.Io.Dir.cwd().readFileAlloc(io, style_path, std.testing.allocator, .limited(max_asset_bytes));
 790     defer std.testing.allocator.free(style);
 791 
 792     try std.testing.expect(std.mem.indexOf(u8, index, "<title>Themed/Docs</title>") != null);
 793     try std.testing.expect(std.mem.indexOf(u8, index, "<link href=\"/zen.css\">") != null);
 794     try std.testing.expect(std.mem.indexOf(u8, index, "<header>Docs Header</header>") != null);
 795     try std.testing.expect(std.mem.indexOf(u8, index, "<h1 id=\"hello\">Hello</h1>") != null);
 796     try std.testing.expectEqualStrings("main{max-width:70ch}\n", style);
 797     try std.testing.expect(!pathExists(layout_asset_path));
 798     try std.testing.expect(!pathExists(style_asset_path));
 799     try std.testing.expect(!pathExists(include_asset_path));
 800 }
 801 
 802 test "site build rebases root relative urls under base" {
 803     var tmp = std.testing.tmpDir(.{});
 804     defer tmp.cleanup();
 805     try tmp.dir.createDirPath(io, "site/.zen");
 806     try tmp.dir.writeFile(io, .{ .sub_path = "site/.zen/layout.html", .data = "<link href=\"/{{style.path}}\"><a href=\"/\">home</a><main>{{content}}</main>" });
 807     try tmp.dir.writeFile(io, .{ .sub_path = "site/.zen/style.css", .data = "main{margin:0}\n" });
 808     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "[guide](/docs/guide/) [rel](other/) ![cdn](//cdn.example.com/x.png)\n" });
 809 
 810     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 811     defer std.testing.allocator.free(source);
 812     const output = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 813     defer std.testing.allocator.free(output);
 814     const out = try std.fs.path.join(std.testing.allocator, &.{ output, "out" });
 815     defer std.testing.allocator.free(out);
 816 
 817     _ = try buildSite(std.testing.allocator, .{
 818         .source = source,
 819         .output = out,
 820         .base = "/tiny",
 821     });
 822 
 823     const index_path = try std.fs.path.join(std.testing.allocator, &.{ out, "index.html" });
 824     defer std.testing.allocator.free(index_path);
 825     const index = try std.Io.Dir.cwd().readFileAlloc(io, index_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
 826     defer std.testing.allocator.free(index);
 827 
 828     try std.testing.expect(std.mem.indexOf(u8, index, "href=\"/tiny/zen.css\"") != null);
 829     try std.testing.expect(std.mem.indexOf(u8, index, "href=\"/tiny/\"") != null);
 830     try std.testing.expect(std.mem.indexOf(u8, index, "href=\"/tiny/docs/guide/\"") != null);
 831     try std.testing.expect(std.mem.indexOf(u8, index, "href=\"other/\"") != null);
 832     try std.testing.expect(std.mem.indexOf(u8, index, "src=\"//cdn.example.com/x.png\"") != null);
 833 }
 834 
 835 test "site build preserves parent-owned root relative urls" {
 836     var tmp = std.testing.tmpDir(.{});
 837     defer tmp.cleanup();
 838     try tmp.dir.createDirPath(io, "site/.zen");
 839     try tmp.dir.writeFile(io, .{
 840         .sub_path = "site/.zen/layout.html",
 841         .data = "<link href=\"/style.css\"><a href=\"/\">home</a><main>{{content}}</main>",
 842     });
 843     try tmp.dir.writeFile(io, .{
 844         .sub_path = "site/.zen/style.css",
 845         .data = "main{margin:0}\n",
 846     });
 847     try tmp.dir.writeFile(io, .{
 848         .sub_path = "site/index.md",
 849         .data = "[parent](/tiny/writing/page/#lineage) [exact](/tiny#top) " ++
 850             "[lookalike](/tiny-docs/page/) ![cdn](//cdn.example.com/x.png)\n",
 851     });
 852 
 853     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 854     defer std.testing.allocator.free(source);
 855     const output = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 856     defer std.testing.allocator.free(output);
 857     const out = try std.fs.path.join(std.testing.allocator, &.{ output, "out" });
 858     defer std.testing.allocator.free(out);
 859 
 860     _ = try buildSite(std.testing.allocator, .{
 861         .source = source,
 862         .output = out,
 863         .base = "/tiny/docs",
 864         .parent_site_base = "/tiny",
 865     });
 866 
 867     const index_path = try std.fs.path.join(
 868         std.testing.allocator,
 869         &.{ out, "index.html" },
 870     );
 871     defer std.testing.allocator.free(index_path);
 872     const index = try std.Io.Dir.cwd().readFileAlloc(
 873         io,
 874         index_path,
 875         std.testing.allocator,
 876         .limited(site_page.default_limits.max_page_bytes),
 877     );
 878     defer std.testing.allocator.free(index);
 879 
 880     try std.testing.expect(std.mem.indexOf(u8, index, "href=\"/tiny/docs/style.css\"") != null);
 881     try std.testing.expect(std.mem.indexOf(u8, index, "href=\"/tiny/docs/\"") != null);
 882     try std.testing.expect(std.mem.indexOf(u8, index, "href=\"/tiny/writing/page/#lineage\"") != null);
 883     try std.testing.expect(std.mem.indexOf(u8, index, "href=\"/tiny#top\"") != null);
 884     try std.testing.expect(std.mem.indexOf(u8, index, "href=\"/tiny/docs/tiny-docs/page/\"") != null);
 885     try std.testing.expect(std.mem.indexOf(u8, index, "src=\"//cdn.example.com/x.png\"") != null);
 886 }
 887 
 888 test "site build rejects malformed base" {
 889     var tmp = std.testing.tmpDir(.{});
 890     defer tmp.cleanup();
 891     try tmp.dir.createDirPath(io, "site");
 892     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "# Home\n" });
 893     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 894     defer std.testing.allocator.free(source);
 895     const output = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 896     defer std.testing.allocator.free(output);
 897     const out = try std.fs.path.join(std.testing.allocator, &.{ output, "out" });
 898     defer std.testing.allocator.free(out);
 899     try std.testing.expectError(error.InvalidBase, buildSite(std.testing.allocator, .{
 900         .source = source,
 901         .output = out,
 902         .base = "tiny",
 903     }));
 904     try std.testing.expectError(error.InvalidBase, buildSite(std.testing.allocator, .{
 905         .source = source,
 906         .output = out,
 907         .base = "/tiny/",
 908     }));
 909     try std.testing.expectError(error.InvalidParentSiteBase, buildSite(std.testing.allocator, .{
 910         .source = source,
 911         .output = out,
 912         .base = "/tiny/docs",
 913         .parent_site_base = "/other",
 914     }));
 915 }
 916 
 917 test "site build keeps curated slide decks badge-free and stamps uncurated listings" {
 918     var tmp = std.testing.tmpDir(.{});
 919     defer tmp.cleanup();
 920     try tmp.dir.createDirPath(io, "site/decks");
 921     try tmp.dir.writeFile(io, .{ .sub_path = "site/decks/curated.md", .data = "---\n" ++
 922         "title: Curated\n" ++
 923         "render: slides\n" ++
 924         "curated: true\n" ++
 925         "---\n" ++
 926         "# Curated\n" });
 927     try tmp.dir.writeFile(io, .{ .sub_path = "site/decks/agent.md", .data = "---\n" ++
 928         "title: Agent\n" ++
 929         "render: slides\n" ++
 930         "---\n" ++
 931         "# Agent\n" });
 932     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "---\n" ++
 933         "title: Home\n" ++
 934         "---\n\n" ++
 935         "```zen-pages\n" ++
 936         "title: Decks\n" ++
 937         "section: decks\n" ++
 938         "```\n" });
 939 
 940     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
 941     defer std.testing.allocator.free(source);
 942     const output = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
 943     defer std.testing.allocator.free(output);
 944     const out = try std.fs.path.join(std.testing.allocator, &.{ output, "out" });
 945     defer std.testing.allocator.free(out);
 946 
 947     _ = try buildSite(std.testing.allocator, .{ .source = source, .output = out });
 948 
 949     const curated_path = try std.fs.path.join(std.testing.allocator, &.{ out, "decks", "curated", "index.html" });
 950     defer std.testing.allocator.free(curated_path);
 951     const agent_path = try std.fs.path.join(std.testing.allocator, &.{ out, "decks", "agent", "index.html" });
 952     defer std.testing.allocator.free(agent_path);
 953     const home_path = try std.fs.path.join(std.testing.allocator, &.{ out, "index.html" });
 954     defer std.testing.allocator.free(home_path);
 955     const curated = try std.Io.Dir.cwd().readFileAlloc(io, curated_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
 956     defer std.testing.allocator.free(curated);
 957     const agent = try std.Io.Dir.cwd().readFileAlloc(io, agent_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
 958     defer std.testing.allocator.free(agent);
 959     const home = try std.Io.Dir.cwd().readFileAlloc(io, home_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
 960     defer std.testing.allocator.free(home);
 961 
 962     try std.testing.expect(std.mem.indexOf(u8, curated, "zen-slide-badge") == null);
 963     try std.testing.expect(std.mem.indexOf(u8, agent, "zen-slide-badge") != null);
 964     try std.testing.expect(std.mem.indexOf(u8, agent, ">SLOP</span>") != null);
 965     try std.testing.expect(std.mem.indexOf(u8, home, "<a href=\"/decks/agent/\">Agent</a> <code>slop</code>") != null);
 966     try std.testing.expect(std.mem.indexOf(u8, home, "<a href=\"/decks/curated/\">Curated</a></li>") != null);
 967 }
 968 
 969 test "site build badges non-curated document pages and filters curated streams" {
 970     var tmp = std.testing.tmpDir(.{});
 971     defer tmp.cleanup();
 972     try tmp.dir.createDirPath(io, "site/writing");
 973     try tmp.dir.createDirPath(io, "site/guides");
 974     try tmp.dir.writeFile(io, .{ .sub_path = "site/writing/essay.md", .data = "---\n" ++
 975         "title: Essay | Notes\n" ++
 976         "type: release\n" ++
 977         "date: 2026-07-01\n" ++
 978         "curated: true\n" ++
 979         "---\n" ++
 980         "# Essay\n" });
 981     try tmp.dir.writeFile(io, .{ .sub_path = "site/guides/machine.md", .data = "---\n" ++
 982         "title: Machine\n" ++
 983         "date: 2026-07-10\n" ++
 984         "---\n" ++
 985         "# Machine\n" });
 986     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "---\n" ++
 987         "title: Home\n" ++
 988         "curated: true\n" ++
 989         "---\n\n" ++
 990         "```zen-pages\n" ++
 991         "curated: only\n" ++
 992         "types: true\n" ++
 993         "format: table\n" ++
 994         "```\n\n" ++
 995         "```zen-pages\n" ++
 996         "title: Slop\n" ++
 997         "curated: exclude\n" ++
 998         "types: true\n" ++
 999         "format: table\n" ++
1000         "```\n" });
1001 
1002     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
1003     defer std.testing.allocator.free(source);
1004     const output = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
1005     defer std.testing.allocator.free(output);
1006     const out = try std.fs.path.join(std.testing.allocator, &.{ output, "out" });
1007     defer std.testing.allocator.free(out);
1008 
1009     const public_result = try buildSite(std.testing.allocator, .{ .source = source, .output = out, .include_slop = false });
1010     try std.testing.expectEqual(@as(usize, 1), public_result.slop);
1011 
1012     const essay_path = try std.fs.path.join(std.testing.allocator, &.{ out, "writing", "essay", "index.html" });
1013     defer std.testing.allocator.free(essay_path);
1014     const machine_path = try std.fs.path.join(std.testing.allocator, &.{ out, "guides", "machine", "index.html" });
1015     defer std.testing.allocator.free(machine_path);
1016     const home_path = try std.fs.path.join(std.testing.allocator, &.{ out, "index.html" });
1017     defer std.testing.allocator.free(home_path);
1018     const essay = try std.Io.Dir.cwd().readFileAlloc(io, essay_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
1019     defer std.testing.allocator.free(essay);
1020     try std.testing.expectError(error.FileNotFound, std.Io.Dir.cwd().readFileAlloc(io, machine_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes)));
1021     const home = try std.Io.Dir.cwd().readFileAlloc(io, home_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
1022     defer std.testing.allocator.free(home);
1023 
1024     const curated_row = "<td class=\"zen-table-align-left\"><a href=\"/writing/essay/\">Essay | Notes</a></td>\n" ++
1025         "<td class=\"zen-table-align-left\"><code>2026-07-01</code></td>\n" ++
1026         "<td class=\"zen-table-align-left\"><code>release</code> <code>writing</code></td>";
1027     const slop_row = "<td class=\"zen-table-align-left\"><a href=\"/guides/machine/\">Machine</a></td>\n" ++
1028         "<td class=\"zen-table-align-left\"><code>2026-07-10</code></td>\n" ++
1029         "<td class=\"zen-table-align-left\"><code>guides</code></td>";
1030     try std.testing.expect(std.mem.indexOf(u8, essay, "zen-page-badge") == null);
1031     try std.testing.expect(std.mem.indexOf(u8, home, curated_row) != null);
1032     try std.testing.expect(std.mem.indexOf(u8, home, ">Slop<") == null);
1033     try std.testing.expect(std.mem.indexOf(u8, home, slop_row) == null);
1034 
1035     const local = try std.fs.path.join(std.testing.allocator, &.{ output, "local" });
1036     defer std.testing.allocator.free(local);
1037     _ = try buildSite(std.testing.allocator, .{ .source = source, .output = local, .include_slop = true });
1038 
1039     const local_machine_path = try std.fs.path.join(std.testing.allocator, &.{ local, "guides", "machine", "index.html" });
1040     defer std.testing.allocator.free(local_machine_path);
1041     const local_home_path = try std.fs.path.join(std.testing.allocator, &.{ local, "index.html" });
1042     defer std.testing.allocator.free(local_home_path);
1043     const machine = try std.Io.Dir.cwd().readFileAlloc(io, local_machine_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
1044     defer std.testing.allocator.free(machine);
1045     const local_home = try std.Io.Dir.cwd().readFileAlloc(io, local_home_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
1046     defer std.testing.allocator.free(local_home);
1047 
1048     try std.testing.expect(std.mem.indexOf(u8, machine, "zen-page-badge") != null);
1049     try std.testing.expect(std.mem.indexOf(u8, machine, ">SLOP</span>") != null);
1050     try std.testing.expect(std.mem.indexOf(u8, local_home, curated_row) != null);
1051     try std.testing.expect(std.mem.indexOf(u8, local_home, slop_row) != null);
1052     const slop_heading = std.mem.indexOf(u8, local_home, ">Slop<").?;
1053     try std.testing.expect(std.mem.indexOf(u8, local_home, curated_row).? < slop_heading);
1054     try std.testing.expect(std.mem.indexOf(u8, local_home, slop_row).? > slop_heading);
1055     try std.testing.expect(std.mem.indexOf(u8, local_home, "<code>slop</code>") == null);
1056 }
1057 
1058 test "site page table rejects unknown formats and over-bound streams" {
1059     var tmp = std.testing.tmpDir(.{});
1060     defer tmp.cleanup();
1061     try tmp.dir.createDirPath(io, "site");
1062     try tmp.dir.writeFile(io, .{
1063         .sub_path = "site/index.md",
1064         .data = "```zen-pages\nformat: grid\n```\n",
1065     });
1066     const source = try tmp.dir.realPathFileAlloc(
1067         io,
1068         "site",
1069         std.testing.allocator,
1070     );
1071     defer std.testing.allocator.free(source);
1072     const output = try tmp.dir.realPathFileAlloc(
1073         io,
1074         ".",
1075         std.testing.allocator,
1076     );
1077     defer std.testing.allocator.free(output);
1078     const out = try std.fs.path.join(
1079         std.testing.allocator,
1080         &.{ output, "out" },
1081     );
1082     defer std.testing.allocator.free(out);
1083 
1084     try std.testing.expectError(
1085         error.InvalidPageListDirective,
1086         buildSite(std.testing.allocator, .{ .source = source, .output = out }),
1087     );
1088 
1089     try tmp.dir.writeFile(io, .{
1090         .sub_path = "site/index.md",
1091         .data = "```zen-pages\nformat: table\n```\n",
1092     });
1093     for (0..max_table_body_rows + 1) |index| {
1094         const path = try std.fmt.allocPrint(
1095             std.testing.allocator,
1096             "site/page-{d}.md",
1097             .{index},
1098         );
1099         defer std.testing.allocator.free(path);
1100         try tmp.dir.writeFile(io, .{ .sub_path = path, .data = "# Page\n" });
1101     }
1102     try std.testing.expectError(
1103         error.PageListTableRowLimitExceeded,
1104         buildSite(std.testing.allocator, .{ .source = source, .output = out }),
1105     );
1106 }
1107 
1108 test "site build rejects unknown render mode" {
1109     var tmp = std.testing.tmpDir(.{});
1110     defer tmp.cleanup();
1111     try tmp.dir.createDirPath(io, "site");
1112     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "---\nrender: slide\n---\n# Home\n" });
1113     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
1114     defer std.testing.allocator.free(source);
1115     const output = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
1116     defer std.testing.allocator.free(output);
1117     const out = try std.fs.path.join(std.testing.allocator, &.{ output, "out" });
1118     defer std.testing.allocator.free(out);
1119     try std.testing.expectError(error.InvalidRenderMode, buildSite(std.testing.allocator, .{
1120         .source = source,
1121         .output = out,
1122     }));
1123 }
1124 
1125 test "site build skips draft pages" {
1126     var tmp = std.testing.tmpDir(.{});
1127     defer tmp.cleanup();
1128     try tmp.dir.createDirPath(io, "site");
1129     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "# Home\n" });
1130     try tmp.dir.writeFile(io, .{ .sub_path = "site/hidden.md", .data = "---\ntitle: Hidden\ndraft: true\n---\n# Hidden\n" });
1131 
1132     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
1133     defer std.testing.allocator.free(source);
1134     const output = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
1135     defer std.testing.allocator.free(output);
1136     const out = try std.fs.path.join(std.testing.allocator, &.{ output, "out" });
1137     defer std.testing.allocator.free(out);
1138 
1139     const skipped = try buildSite(std.testing.allocator, .{ .source = source, .output = out });
1140     try std.testing.expectEqual(@as(usize, 1), skipped.pages);
1141     try std.testing.expectEqual(@as(usize, 1), skipped.drafts);
1142     const hidden_path = try std.fs.path.join(std.testing.allocator, &.{ out, "hidden", "index.html" });
1143     defer std.testing.allocator.free(hidden_path);
1144     try std.testing.expect(!pathExists(hidden_path));
1145 
1146     const included = try buildSite(std.testing.allocator, .{ .source = source, .output = out, .include_drafts = true });
1147     try std.testing.expectEqual(@as(usize, 2), included.pages);
1148     try std.testing.expectEqual(@as(usize, 0), included.drafts);
1149     try std.testing.expect(pathExists(hidden_path));
1150 }
1151 
1152 test "site page lists follow draft visibility" {
1153     var tmp = std.testing.tmpDir(.{});
1154     defer tmp.cleanup();
1155     try tmp.dir.createDirPath(io, "site/writing");
1156     try tmp.dir.createDirPath(io, "site/projects");
1157     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "---\ntitle: Home\n---\n" ++
1158         "```zen-pages\n" ++
1159         "title: Writing\n" ++
1160         "section: writing\n" ++
1161         "```\n\n" ++
1162         "```zen-pages\n" ++
1163         "title: Projects\n" ++
1164         "section: projects\n" ++
1165         "```\n" });
1166     try tmp.dir.writeFile(io, .{ .sub_path = "site/writing/published.md", .data = "---\ntitle: Published\ndate: 2026-07-05\n---\n# Published\n" });
1167     try tmp.dir.writeFile(io, .{ .sub_path = "site/writing/draft.md", .data = "---\ntitle: Draft\ndraft: true\n---\n# Draft\n" });
1168     try tmp.dir.writeFile(io, .{ .sub_path = "site/projects/tldr.md", .data = "---\ntitle: Link Loader\ndraft: true\n---\n# Link Loader\n" });
1169 
1170     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
1171     defer std.testing.allocator.free(source);
1172     const output = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
1173     defer std.testing.allocator.free(output);
1174     const out = try std.fs.path.join(std.testing.allocator, &.{ output, "out" });
1175     defer std.testing.allocator.free(out);
1176     const index_path = try std.fs.path.join(std.testing.allocator, &.{ out, "index.html" });
1177     defer std.testing.allocator.free(index_path);
1178 
1179     const hidden = try buildSite(std.testing.allocator, .{ .source = source, .output = out });
1180     try std.testing.expectEqual(@as(usize, 2), hidden.pages);
1181     try std.testing.expectEqual(@as(usize, 2), hidden.drafts);
1182     const hidden_index = try std.Io.Dir.cwd().readFileAlloc(io, index_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
1183     defer std.testing.allocator.free(hidden_index);
1184     try std.testing.expect(std.mem.indexOf(u8, hidden_index, "<h2 id=\"writing\">Writing</h2>") != null);
1185     try std.testing.expect(std.mem.indexOf(u8, hidden_index, "<code>2026-07-05</code> <a href=\"/writing/published/\">Published</a>") != null);
1186     try std.testing.expect(std.mem.indexOf(u8, hidden_index, "Draft") == null);
1187     try std.testing.expect(std.mem.indexOf(u8, hidden_index, "Projects") == null);
1188 
1189     const shown = try buildSite(std.testing.allocator, .{ .source = source, .output = out, .include_drafts = true });
1190     try std.testing.expectEqual(@as(usize, 4), shown.pages);
1191     try std.testing.expectEqual(@as(usize, 0), shown.drafts);
1192     const shown_index = try std.Io.Dir.cwd().readFileAlloc(io, index_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
1193     defer std.testing.allocator.free(shown_index);
1194     try std.testing.expect(std.mem.indexOf(u8, shown_index, "<h2 id=\"projects\">Projects</h2>") != null);
1195     try std.testing.expect(std.mem.indexOf(u8, shown_index, "<code>draft</code> <a href=\"/writing/draft/\">Draft</a>") != null);
1196     try std.testing.expect(std.mem.indexOf(u8, shown_index, "<code>draft</code> <a href=\"/projects/tldr/\">Link Loader</a>") != null);
1197 }
1198 
1199 test "site build rejects output inside source" {
1200     var tmp = std.testing.tmpDir(.{});
1201     defer tmp.cleanup();
1202     try tmp.dir.createDirPath(io, "site/out");
1203     try tmp.dir.writeFile(io, .{ .sub_path = "site/out/stale.txt", .data = "stale" });
1204     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "# Home\n" });
1205     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
1206     defer std.testing.allocator.free(source);
1207     const output = try tmp.dir.realPathFileAlloc(io, "site/out", std.testing.allocator);
1208     defer std.testing.allocator.free(output);
1209     try std.testing.expectError(error.OutputInsideSource, buildSite(std.testing.allocator, .{
1210         .source = source,
1211         .output = output,
1212     }));
1213     const stale = try std.fs.path.join(std.testing.allocator, &.{ output, "stale.txt" });
1214     defer std.testing.allocator.free(stale);
1215     try std.testing.expect(pathExists(stale));
1216 }
1217 
1218 test "site build names the failing page and equation" {
1219     var tmp = std.testing.tmpDir(.{});
1220     defer tmp.cleanup();
1221     try tmp.dir.createDirPath(io, "site/writing");
1222     try tmp.dir.writeFile(io, .{ .sub_path = "site/writing/broken.md", .data = "# Broken\n\nSee $\\frac{1}$ here.\n" });
1223     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
1224     defer std.testing.allocator.free(source);
1225     const output = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
1226     defer std.testing.allocator.free(output);
1227     const out = try std.fs.path.join(std.testing.allocator, &.{ output, "out" });
1228     defer std.testing.allocator.free(out);
1229 
1230     var diag: diagnostic.Diagnostic = .{};
1231     try std.testing.expectError(error.InvalidEquation, buildSite(std.testing.allocator, .{
1232         .source = source,
1233         .output = out,
1234         .diagnostic = &diag,
1235     }));
1236     try std.testing.expectEqualStrings("writing/broken.md", diag.page());
1237     try std.testing.expectEqualStrings("\\frac{1}", diag.equation());
1238     try std.testing.expectEqualStrings("missing argument", diag.reason);
1239     try std.testing.expectEqual(@as(usize, 8), diag.offset);
1240 }
1241 
1242 test "site build names the failing page on frontmatter errors" {
1243     var tmp = std.testing.tmpDir(.{});
1244     defer tmp.cleanup();
1245     try tmp.dir.createDirPath(io, "site");
1246     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "---\ntitle: Broken\n# Missing close\n" });
1247     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
1248     defer std.testing.allocator.free(source);
1249     const output = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator);
1250     defer std.testing.allocator.free(output);
1251     const out = try std.fs.path.join(std.testing.allocator, &.{ output, "out" });
1252     defer std.testing.allocator.free(out);
1253 
1254     var diag: diagnostic.Diagnostic = .{};
1255     try std.testing.expectError(error.UnclosedFrontmatter, buildSite(std.testing.allocator, .{
1256         .source = source,
1257         .output = out,
1258         .diagnostic = &diag,
1259     }));
1260     try std.testing.expectEqualStrings("index.md", diag.page());
1261     try std.testing.expectEqualStrings("", diag.equation());
1262 }
1263 
1264 test "site build preserves existing output when rendering fails" {
1265     var tmp = std.testing.tmpDir(.{});
1266     defer tmp.cleanup();
1267     try tmp.dir.createDirPath(io, "site");
1268     try tmp.dir.createDirPath(io, "out");
1269     try tmp.dir.writeFile(io, .{ .sub_path = "out/index.html", .data = "previous" });
1270     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "---\ntitle: Broken\n# Missing close\n" });
1271 
1272     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
1273     defer std.testing.allocator.free(source);
1274     const output = try tmp.dir.realPathFileAlloc(io, "out", std.testing.allocator);
1275     defer std.testing.allocator.free(output);
1276     try std.testing.expectError(error.UnclosedFrontmatter, buildSite(std.testing.allocator, .{
1277         .source = source,
1278         .output = output,
1279     }));
1280 
1281     const index_path = try std.fs.path.join(std.testing.allocator, &.{ output, "index.html" });
1282     defer std.testing.allocator.free(index_path);
1283     const previous = try std.Io.Dir.cwd().readFileAlloc(io, index_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
1284     defer std.testing.allocator.free(previous);
1285     try std.testing.expectEqualStrings("previous", previous);
1286 
1287     const staging = try stagingPath(std.testing.allocator, output);
1288     defer std.testing.allocator.free(staging);
1289     const backup = try backupPath(std.testing.allocator, output);
1290     defer std.testing.allocator.free(backup);
1291     try std.testing.expect(!pathExists(staging));
1292     try std.testing.expect(!pathExists(backup));
1293 }
1294 
1295 test "site build publishes only after its preparation hook succeeds" {
1296     var tmp = std.testing.tmpDir(.{});
1297     defer tmp.cleanup();
1298     try tmp.dir.createDirPath(io, "site");
1299     try tmp.dir.createDirPath(io, "out");
1300     try tmp.dir.writeFile(io, .{ .sub_path = "out/index.html", .data = "previous" });
1301     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "# Prepared\n" });
1302 
1303     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
1304     defer std.testing.allocator.free(source);
1305     const output = try tmp.dir.realPathFileAlloc(io, "out", std.testing.allocator);
1306     defer std.testing.allocator.free(output);
1307     var fixture = PublishHookFixture{ .fail = true };
1308     try std.testing.expectError(error.PublishRejected, buildSite(
1309         std.testing.allocator,
1310         .{
1311             .source = source,
1312             .output = output,
1313             .before_publish = fixture.hook(),
1314         },
1315     ));
1316     try std.testing.expectEqual(@as(usize, 1), fixture.calls);
1317 
1318     const index_path = try std.fs.path.join(
1319         std.testing.allocator,
1320         &.{ output, "index.html" },
1321     );
1322     defer std.testing.allocator.free(index_path);
1323     const previous = try std.Io.Dir.cwd().readFileAlloc(
1324         io,
1325         index_path,
1326         std.testing.allocator,
1327         .limited(site_page.default_limits.max_page_bytes),
1328     );
1329     defer std.testing.allocator.free(previous);
1330     try std.testing.expectEqualStrings("previous", previous);
1331     const staging = try stagingPath(std.testing.allocator, output);
1332     defer std.testing.allocator.free(staging);
1333     const backup = try backupPath(std.testing.allocator, output);
1334     defer std.testing.allocator.free(backup);
1335     try std.testing.expect(!pathExists(staging));
1336     try std.testing.expect(!pathExists(backup));
1337 
1338     fixture.fail = false;
1339     _ = try buildSite(std.testing.allocator, .{
1340         .source = source,
1341         .output = output,
1342         .before_publish = fixture.hook(),
1343     });
1344     try std.testing.expectEqual(@as(usize, 2), fixture.calls);
1345     const marker_path = try std.fs.path.join(
1346         std.testing.allocator,
1347         &.{ output, "hook.txt" },
1348     );
1349     defer std.testing.allocator.free(marker_path);
1350     const marker = try std.Io.Dir.cwd().readFileAlloc(
1351         io,
1352         marker_path,
1353         std.testing.allocator,
1354         .limited(32),
1355     );
1356     defer std.testing.allocator.free(marker);
1357     try std.testing.expectEqualStrings("prepared", marker);
1358     try std.testing.expectError(error.PublishHookRequiresClean, buildSite(
1359         std.testing.allocator,
1360         .{
1361             .source = source,
1362             .output = output,
1363             .clean = false,
1364             .before_publish = fixture.hook(),
1365         },
1366     ));
1367 }
1368 
1369 test "site build preserves existing output when theme rendering fails" {
1370     var tmp = std.testing.tmpDir(.{});
1371     defer tmp.cleanup();
1372     try tmp.dir.createDirPath(io, "site/.zen");
1373     try tmp.dir.createDirPath(io, "out");
1374     try tmp.dir.writeFile(io, .{ .sub_path = "out/index.html", .data = "previous" });
1375     try tmp.dir.writeFile(io, .{ .sub_path = "site/.zen/layout.html", .data = "<main>{{page.title}}</main>" });
1376     try tmp.dir.writeFile(io, .{ .sub_path = "site/index.md", .data = "# Home\n" });
1377 
1378     const source = try tmp.dir.realPathFileAlloc(io, "site", std.testing.allocator);
1379     defer std.testing.allocator.free(source);
1380     const output = try tmp.dir.realPathFileAlloc(io, "out", std.testing.allocator);
1381     defer std.testing.allocator.free(output);
1382     try std.testing.expectError(error.UnknownPlaceholder, buildSite(std.testing.allocator, .{
1383         .source = source,
1384         .output = output,
1385     }));
1386 
1387     const index_path = try std.fs.path.join(std.testing.allocator, &.{ output, "index.html" });
1388     defer std.testing.allocator.free(index_path);
1389     const previous = try std.Io.Dir.cwd().readFileAlloc(io, index_path, std.testing.allocator, .limited(site_page.default_limits.max_page_bytes));
1390     defer std.testing.allocator.free(previous);
1391     try std.testing.expectEqualStrings("previous", previous);
1392 
1393     const staging = try stagingPath(std.testing.allocator, output);
1394     defer std.testing.allocator.free(staging);
1395     const backup = try backupPath(std.testing.allocator, output);
1396     defer std.testing.allocator.free(backup);
1397     try std.testing.expect(!pathExists(staging));
1398     try std.testing.expect(!pathExists(backup));
1399 }