lib/zen/src/site/path/transform.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2
3 const model = @import("model.zig");
4
5 const index_name = "index.html";
6 const html_suffix = ".html";
7
8 pub fn targetLength(relative_path: []const u8, clean_urls: bool) error{CapacityOverflow}!usize {
9 const base = std.fs.path.basename(relative_path);
10 const directory = std.fs.path.dirname(relative_path);
11 const stem = markdownStem(base);
12 if (clean_urls) {
13 if (std.mem.eql(u8, stem, "index") or std.mem.eql(u8, stem, "README")) {
14 return joinedLength(&.{ directory orelse "", index_name });
15 }
16 return joinedLength(&.{ directory orelse "", stem, index_name });
17 }
18 return added(try joinedLength(&.{ directory orelse "", stem }), html_suffix.len);
19 }
20
21 pub fn writeTarget(output: []u8, relative_path: []const u8, clean_urls: bool) []u8 {
22 const base = std.fs.path.basename(relative_path);
23 const directory = std.fs.path.dirname(relative_path);
24 const stem = markdownStem(base);
25 var index: usize = 0;
26 if (clean_urls) {
27 if (std.mem.eql(u8, stem, "index") or std.mem.eql(u8, stem, "README")) {
28 writeJoined(output, &index, &.{ directory orelse "", index_name });
29 } else {
30 writeJoined(output, &index, &.{ directory orelse "", stem, index_name });
31 }
32 } else {
33 writeJoined(output, &index, &.{ directory orelse "", stem });
34 appendRaw(output, &index, html_suffix);
35 }
36 std.debug.assert(index == output.len);
37 return output;
38 }
39
40 pub fn publicLength(
41 target_bytes: usize,
42 clean_urls: bool,
43 kind: model.PublicKind,
44 ) error{CapacityOverflow}!usize {
45 const content_bytes = switch (kind) {
46 .target => target_bytes,
47 .url => if (clean_urls) target_bytes - index_name.len else target_bytes,
48 };
49 return added(1, content_bytes);
50 }
51
52 pub fn writePublic(
53 output: []u8,
54 target: []const u8,
55 clean_urls: bool,
56 kind: model.PublicKind,
57 ) []u8 {
58 const content = switch (kind) {
59 .target => target,
60 .url => if (clean_urls) target[0 .. target.len - index_name.len] else target,
61 };
62 output[0] = '/';
63 for (content, output[1..]) |byte, *destination| {
64 destination.* = if (std.fs.path.isSep(byte)) '/' else byte;
65 }
66 return output;
67 }
68
69 fn markdownStem(base: []const u8) []const u8 {
70 if (std.mem.endsWith(u8, base, ".markdown")) return base[0 .. base.len - ".markdown".len];
71 if (std.mem.endsWith(u8, base, ".md")) return base[0 .. base.len - ".md".len];
72 return base;
73 }
74
75 fn joinedLength(parts: []const []const u8) error{CapacityOverflow}!usize {
76 var total: usize = 0;
77 var previous: []const u8 = "";
78 for (parts) |part| {
79 if (part.len == 0) continue;
80 if (previous.len != 0) {
81 const previous_sep = std.fs.path.isSep(previous[previous.len - 1]);
82 const current_sep = std.fs.path.isSep(part[0]);
83 if (!previous_sep and !current_sep) total = try added(total, 1);
84 total = try added(total, if (previous_sep and current_sep) part.len - 1 else part.len);
85 } else {
86 total = try added(total, part.len);
87 }
88 previous = part;
89 }
90 return total;
91 }
92
93 fn writeJoined(output: []u8, index: *usize, parts: []const []const u8) void {
94 var previous: []const u8 = "";
95 for (parts) |part| {
96 if (part.len == 0) continue;
97 if (previous.len == 0) {
98 appendRaw(output, index, part);
99 } else {
100 const previous_sep = std.fs.path.isSep(previous[previous.len - 1]);
101 const current_sep = std.fs.path.isSep(part[0]);
102 if (!previous_sep and !current_sep) {
103 output[index.*] = std.fs.path.sep;
104 index.* += 1;
105 }
106 appendRaw(output, index, if (previous_sep and current_sep) part[1..] else part);
107 }
108 previous = part;
109 }
110 }
111
112 fn appendRaw(output: []u8, index: *usize, value: []const u8) void {
113 @memcpy(output[index.*..][0..value.len], value);
114 index.* += value.len;
115 }
116
117 fn added(left: usize, right: usize) error{CapacityOverflow}!usize {
118 return std.math.add(usize, left, right) catch error.CapacityOverflow;
119 }
120
121 test "site path transforms match allocating path semantics" {
122 const relative = try std.fs.path.join(std.testing.allocator, &.{ "guides", "start.md" });
123 defer std.testing.allocator.free(relative);
124 const target_bytes = try targetLength(relative, true);
125 var target_buffer: [128]u8 = undefined;
126 const target = writeTarget(target_buffer[0..target_bytes], relative, true);
127 const expected_target = try std.fs.path.join(std.testing.allocator, &.{ "guides", "start", index_name });
128 defer std.testing.allocator.free(expected_target);
129 try std.testing.expectEqualStrings(expected_target, target);
130
131 const public_bytes = try publicLength(target.len, true, .url);
132 var public_buffer: [128]u8 = undefined;
133 const public = writePublic(public_buffer[0..public_bytes], target, true, .url);
134 try std.testing.expectEqualStrings("/guides/start/", public);
135
136 const flat_bytes = try targetLength(relative, false);
137 const flat = writeTarget(target_buffer[0..flat_bytes], relative, false);
138 const expected_flat = try std.fs.path.join(std.testing.allocator, &.{ "guides", "start.html" });
139 defer std.testing.allocator.free(expected_flat);
140 try std.testing.expectEqualStrings(expected_flat, flat);
141 }
142
143 test "bounded and allocating site paths preserve bytes" {
144 const cases = [_][]const u8{
145 "index.md",
146 "README.markdown",
147 "guides/start.md",
148 "guides/reference/index.markdown",
149 "notes",
150 };
151 var target_buffer: [256]u8 = undefined;
152 var public_buffer: [256]u8 = undefined;
153 for (cases) |relative_path| {
154 for ([_]bool{ true, false }) |clean_urls| {
155 const expected_target = try allocatingTarget(
156 std.testing.allocator,
157 relative_path,
158 clean_urls,
159 );
160 defer std.testing.allocator.free(expected_target);
161 const target_bytes = try targetLength(relative_path, clean_urls);
162 const target = writeTarget(
163 target_buffer[0..target_bytes],
164 relative_path,
165 clean_urls,
166 );
167 try std.testing.expectEqualStrings(expected_target, target);
168 for ([_]model.PublicKind{ .target, .url }) |kind| {
169 const expected_public = try allocatingPublic(
170 std.testing.allocator,
171 expected_target,
172 clean_urls,
173 kind,
174 );
175 defer std.testing.allocator.free(expected_public);
176 const public_bytes = try publicLength(target.len, clean_urls, kind);
177 const public = writePublic(
178 public_buffer[0..public_bytes],
179 target,
180 clean_urls,
181 kind,
182 );
183 try std.testing.expectEqualStrings(expected_public, public);
184 }
185 }
186 }
187 }
188
189 fn allocatingTarget(
190 allocator: std.mem.Allocator,
191 relative_path: []const u8,
192 clean_urls: bool,
193 ) std.mem.Allocator.Error![]u8 {
194 const base = std.fs.path.basename(relative_path);
195 const directory = std.fs.path.dirname(relative_path);
196 const stem = markdownStem(base);
197 if (clean_urls) {
198 if (std.mem.eql(u8, stem, "index") or std.mem.eql(u8, stem, "README")) {
199 if (directory) |parent| return try std.fs.path.join(allocator, &.{ parent, index_name });
200 return try allocator.dupe(u8, index_name);
201 }
202 if (directory) |parent| return try std.fs.path.join(allocator, &.{ parent, stem, index_name });
203 return try std.fs.path.join(allocator, &.{ stem, index_name });
204 }
205 const file = try std.fmt.allocPrint(allocator, "{s}{s}", .{ stem, html_suffix });
206 defer allocator.free(file);
207 if (directory) |parent| return try std.fs.path.join(allocator, &.{ parent, file });
208 return try allocator.dupe(u8, file);
209 }
210
211 fn allocatingPublic(
212 allocator: std.mem.Allocator,
213 target: []const u8,
214 clean_urls: bool,
215 kind: model.PublicKind,
216 ) std.mem.Allocator.Error![]u8 {
217 const content = switch (kind) {
218 .target => target,
219 .url => if (clean_urls) target[0 .. target.len - index_name.len] else target,
220 };
221 var output = try allocator.alloc(u8, content.len + 1);
222 output[0] = '/';
223 for (content, output[1..]) |byte, *destination| {
224 destination.* = if (std.fs.path.isSep(byte)) '/' else byte;
225 }
226 return output;
227 }