| 1 | //! The TodoMVC page, written straight to a `std.Io.Writer`. |
| 2 | //! |
| 3 | //! The markup follows TodoMVC's own template (shared/reference.html). Each |
| 4 | //! action is a real form, so the page works with JavaScript off; the extra |
| 5 | //! no-JS controls carry a `nojs-` class that static/app.css hides once the |
| 6 | //! script has marked the page with `html.js`. |
| 7 | |
| 8 | const std = @import("std"); |
| 9 | const Writer = std.Io.Writer; |
| 10 | const Todo = @import("db.zig").Todo; |
| 11 | |
| 12 | pub const Filter = enum { |
| 13 | all, |
| 14 | active, |
| 15 | completed, |
| 16 | |
| 17 | pub fn fromField(value: []const u8) Filter { |
| 18 | return std.meta.stringToEnum(Filter, value) orelse .all; |
| 19 | } |
| 20 | |
| 21 | pub fn fromPath(p: []const u8) ?Filter { |
| 22 | if (std.mem.eql(u8, p, "/")) return .all; |
| 23 | if (std.mem.eql(u8, p, "/active")) return .active; |
| 24 | if (std.mem.eql(u8, p, "/completed")) return .completed; |
| 25 | return null; |
| 26 | } |
| 27 | |
| 28 | pub fn path(f: Filter) []const u8 { |
| 29 | return switch (f) { |
| 30 | .all => "/", |
| 31 | .active => "/active", |
| 32 | .completed => "/completed", |
| 33 | }; |
| 34 | } |
| 35 | |
| 36 | fn shows(f: Filter, t: Todo) bool { |
| 37 | return switch (f) { |
| 38 | .all => true, |
| 39 | .active => !t.completed, |
| 40 | .completed => t.completed, |
| 41 | }; |
| 42 | } |
| 43 | }; |
| 44 | |
| 45 | pub const Page = struct { |
| 46 | todos: []const Todo, |
| 47 | filter: Filter, |
| 48 | /// The item whose editor is open, from `?edit=<id>`. |
| 49 | editing: ?i64 = null, |
| 50 | }; |
| 51 | |
| 52 | pub fn render(w: *Writer, page: Page) Writer.Error!void { |
| 53 | var completed: usize = 0; |
| 54 | for (page.todos) |t| completed += @intFromBool(t.completed); |
| 55 | const left = page.todos.len - completed; |
| 56 | const empty = page.todos.len == 0; |
| 57 | const filter = @tagName(page.filter); |
| 58 | |
| 59 | try w.writeAll( |
| 60 | \\<!doctype html> |
| 61 | \\<html lang="en"> |
| 62 | \\<head> |
| 63 | \\<meta charset="utf-8"> |
| 64 | \\<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 65 | \\<title>TodoMVC</title> |
| 66 | \\<link rel="stylesheet" href="/static/base.css"> |
| 67 | \\<link rel="stylesheet" href="/static/index.css"> |
| 68 | \\<link rel="stylesheet" href="/static/app.css"> |
| 69 | \\<script>document.documentElement.classList.add("js")</script> |
| 70 | \\<script type="module" src="/static/app.js"></script> |
| 71 | \\</head> |
| 72 | \\<body> |
| 73 | \\<section class="todoapp"> |
| 74 | \\<header class="header"> |
| 75 | \\<h1>todos</h1> |
| 76 | \\<form method="post" action="/todos"> |
| 77 | \\ |
| 78 | ); |
| 79 | try hiddenFilter(w, filter); |
| 80 | try w.print( |
| 81 | \\<input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"{s}> |
| 82 | \\</form> |
| 83 | \\</header> |
| 84 | \\<section class="main"{s}> |
| 85 | \\<form method="post" action="/todos/toggle-all"> |
| 86 | \\ |
| 87 | , .{ |
| 88 | if (page.editing == null) " autofocus" else "", |
| 89 | if (empty) " hidden" else "", |
| 90 | }); |
| 91 | try hiddenFilter(w, filter); |
| 92 | try w.print( |
| 93 | \\<input id="toggle-all" class="toggle-all" type="checkbox"{s}> |
| 94 | \\<label for="toggle-all">Mark all as complete</label> |
| 95 | \\<button class="nojs-toggle-all" data-action="toggle-all">Mark all as complete</button> |
| 96 | \\</form> |
| 97 | \\<ul class="todo-list"> |
| 98 | \\ |
| 99 | , .{if (!empty and left == 0) " checked" else ""}); |
| 100 | |
| 101 | for (page.todos) |t| { |
| 102 | if (page.filter.shows(t)) try item(w, t, page.filter, page.editing == t.id); |
| 103 | } |
| 104 | |
| 105 | try w.print( |
| 106 | \\</ul> |
| 107 | \\</section> |
| 108 | \\<footer class="footer"{s}> |
| 109 | \\<span class="todo-count"><strong>{d}</strong> {s} left</span> |
| 110 | \\<ul class="filters"> |
| 111 | \\ |
| 112 | , .{ if (empty) " hidden" else "", left, if (left == 1) "item" else "items" }); |
| 113 | inline for (.{ .{ Filter.all, "All" }, .{ Filter.active, "Active" }, .{ Filter.completed, "Completed" } }) |link| { |
| 114 | try w.print("<li><a{s} href=\"{s}\">{s}</a></li>\n", .{ |
| 115 | if (page.filter == link[0]) " class=\"selected\"" else "", |
| 116 | link[0].path(), |
| 117 | link[1], |
| 118 | }); |
| 119 | } |
| 120 | try w.writeAll("</ul>\n"); |
| 121 | if (completed > 0) { |
| 122 | try w.writeAll("<form method=\"post\" action=\"/todos/clear-completed\">\n"); |
| 123 | try hiddenFilter(w, filter); |
| 124 | try w.writeAll("<button class=\"clear-completed\">Clear completed</button>\n</form>\n"); |
| 125 | } |
| 126 | try w.writeAll( |
| 127 | \\</footer> |
| 128 | \\</section> |
| 129 | \\<footer class="info"> |
| 130 | \\<p>Double-click to edit a todo</p> |
| 131 | \\<p>Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> · Zig</p> |
| 132 | \\<p>Part of <a href="http://todomvc.com">TodoMVC</a></p> |
| 133 | \\</footer> |
| 134 | \\</body> |
| 135 | \\</html> |
| 136 | \\ |
| 137 | ); |
| 138 | } |
| 139 | |
| 140 | fn item(w: *Writer, t: Todo, filter: Filter, editing: bool) Writer.Error!void { |
| 141 | const f = @tagName(filter); |
| 142 | const classes: []const u8 = if (t.completed and editing) |
| 143 | " class=\"completed editing\"" |
| 144 | else if (t.completed) |
| 145 | " class=\"completed\"" |
| 146 | else if (editing) |
| 147 | " class=\"editing\"" |
| 148 | else |
| 149 | ""; |
| 150 | try w.print("<li data-id=\"{d}\"{s}>\n<div class=\"view\">\n", .{ t.id, classes }); |
| 151 | try w.print("<input class=\"toggle\" type=\"checkbox\"{s}>\n<label>", .{if (t.completed) " checked" else ""}); |
| 152 | try escape(w, t.title); |
| 153 | try w.print("</label>\n<form method=\"post\" action=\"/todos/{d}/toggle\">\n", .{t.id}); |
| 154 | try hiddenFilter(w, f); |
| 155 | try w.print( |
| 156 | \\<button class="nojs-toggle" data-action="toggle">{s}</button> |
| 157 | \\</form> |
| 158 | \\<a class="nojs-edit" data-action="edit" href="{s}?edit={d}">Edit</a> |
| 159 | \\<form method="post" action="/todos/{d}/destroy"> |
| 160 | \\ |
| 161 | , .{ if (t.completed) "Mark as active" else "Mark as complete", filter.path(), t.id, t.id }); |
| 162 | try hiddenFilter(w, f); |
| 163 | try w.print( |
| 164 | \\<button class="destroy" aria-label="Delete"></button> |
| 165 | \\</form> |
| 166 | \\</div> |
| 167 | \\<form method="post" action="/todos/{d}/edit"> |
| 168 | \\ |
| 169 | , .{t.id}); |
| 170 | try hiddenFilter(w, f); |
| 171 | try w.writeAll("<input class=\"edit\" name=\"title\" value=\""); |
| 172 | try escape(w, t.title); |
| 173 | try w.print("\"{s}>\n</form>\n</li>\n", .{if (editing) " autofocus" else ""}); |
| 174 | } |
| 175 | |
| 176 | fn hiddenFilter(w: *Writer, filter: []const u8) Writer.Error!void { |
| 177 | try w.print("<input type=\"hidden\" name=\"filter\" value=\"{s}\">\n", .{filter}); |
| 178 | } |
| 179 | |
| 180 | /// Escapes text for an HTML text node or a double-quoted attribute. |
| 181 | pub fn escape(w: *Writer, s: []const u8) Writer.Error!void { |
| 182 | var start: usize = 0; |
| 183 | for (s, 0..) |ch, i| { |
| 184 | const entity: []const u8 = switch (ch) { |
| 185 | '&' => "&", |
| 186 | '<' => "<", |
| 187 | '>' => ">", |
| 188 | '"' => """, |
| 189 | '\'' => "'", |
| 190 | else => continue, |
| 191 | }; |
| 192 | try w.writeAll(s[start..i]); |
| 193 | try w.writeAll(entity); |
| 194 | start = i + 1; |
| 195 | } |
| 196 | try w.writeAll(s[start..]); |
| 197 | } |
| 198 | |
| 199 | test escape { |
| 200 | var buf: [128]u8 = undefined; |
| 201 | var w: Writer = .fixed(&buf); |
| 202 | try escape(&w, "<img src=x onerror=\"alert('1')\"> & naïve 🚀"); |
| 203 | try std.testing.expectEqualStrings( |
| 204 | "<img src=x onerror="alert('1')"> & naïve 🚀", |
| 205 | w.buffered(), |
| 206 | ); |
| 207 | } |
| 208 | |
| 209 | fn renderToString(gpa: std.mem.Allocator, page: Page) ![]u8 { |
| 210 | var out: Writer.Allocating = .init(gpa); |
| 211 | defer out.deinit(); |
| 212 | try render(&out.writer, page); |
| 213 | return out.toOwnedSlice(); |
| 214 | } |
| 215 | |
| 216 | test "empty list hides main and footer" { |
| 217 | const html = try renderToString(std.testing.allocator, .{ .todos = &.{}, .filter = .all }); |
| 218 | defer std.testing.allocator.free(html); |
| 219 | try std.testing.expect(std.mem.indexOf(u8, html, "<section class=\"main\" hidden>") != null); |
| 220 | try std.testing.expect(std.mem.indexOf(u8, html, "<footer class=\"footer\" hidden>") != null); |
| 221 | try std.testing.expect(std.mem.indexOf(u8, html, "clear-completed\"") == null); |
| 222 | } |
| 223 | |
| 224 | test "filters, counter and editing" { |
| 225 | const todos = [_]Todo{ |
| 226 | .{ .id = 1, .title = "Buy milk", .completed = true }, |
| 227 | .{ .id = 2, .title = "Walk <the> dog", .completed = false }, |
| 228 | }; |
| 229 | const html = try renderToString(std.testing.allocator, .{ .todos = &todos, .filter = .active, .editing = 2 }); |
| 230 | defer std.testing.allocator.free(html); |
| 231 | try std.testing.expect(std.mem.indexOf(u8, html, "<strong>1</strong> item left") != null); |
| 232 | try std.testing.expect(std.mem.indexOf(u8, html, "data-id=\"1\"") == null); |
| 233 | try std.testing.expect(std.mem.indexOf(u8, html, "<li data-id=\"2\" class=\"editing\">") != null); |
| 234 | try std.testing.expect(std.mem.indexOf(u8, html, "<label>Walk <the> dog</label>") != null); |
| 235 | try std.testing.expect(std.mem.indexOf(u8, html, "<a class=\"selected\" href=\"/active\">") != null); |
| 236 | try std.testing.expect(std.mem.indexOf(u8, html, "href=\"/active?edit=2\"") != null); |
| 237 | try std.testing.expect(std.mem.indexOf(u8, html, "<button class=\"clear-completed\">") != null); |
| 238 | try std.testing.expect(std.mem.indexOf(u8, html, "class=\"new-todo\" name=\"title\" placeholder=\"What needs to be done?\" autocomplete=\"off\">") != null); |
| 239 | } |
| 240 | |