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