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 <home>\">") != 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 "\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/) \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/) \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 }