| 1 | /// The three views of a list, named as the contract's `filter` field names them. |
| 2 | public enum Filter: String, Sendable, CaseIterable { |
| 3 | case all, active, completed |
| 4 | |
| 5 | /// A missing or unknown value means `all`. |
| 6 | init(field: String?) { |
| 7 | self = field.flatMap(Filter.init(rawValue:)) ?? .all |
| 8 | } |
| 9 | |
| 10 | var path: String { |
| 11 | switch self { |
| 12 | case .all: "/" |
| 13 | case .active: "/active" |
| 14 | case .completed: "/completed" |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | func includes(_ todo: Todo) -> Bool { |
| 19 | switch self { |
| 20 | case .all: true |
| 21 | case .active: !todo.completed |
| 22 | case .completed: todo.completed |
| 23 | } |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | /// Server-rendered HTML. Everything a visitor typed goes through `escape`. |
| 28 | /// |
| 29 | /// The markup follows `shared/reference.html`. The controls marked |
| 30 | /// `class="nojs"` are what a page without JavaScript uses; `app.css` lays the |
| 31 | /// toggle buttons exactly over the checkboxes they stand in for, and hides |
| 32 | /// every `nojs` control once the `js` class is on `<html>`. |
| 33 | enum Page { |
| 34 | static let language = "Swift" |
| 35 | |
| 36 | static func document(todos: [Todo], filter: Filter, editing: Int?, version: String) -> String { |
| 37 | var html = """ |
| 38 | <!doctype html> |
| 39 | <html lang="en"> |
| 40 | <head> |
| 41 | <meta charset="utf-8"> |
| 42 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 43 | <title>TodoMVC</title> |
| 44 | <script>document.documentElement.className = "js"</script> |
| 45 | <link rel="stylesheet" href="/static/base.css"> |
| 46 | <link rel="stylesheet" href="/static/index.css"> |
| 47 | <link rel="stylesheet" href="/static/app.css?v=\(escape(version))"> |
| 48 | <script type="module" src="/static/app.js?v=\(escape(version))"></script> |
| 49 | </head> |
| 50 | <body> |
| 51 | <section class="todoapp"> |
| 52 | <header class="header"> |
| 53 | <h1>todos</h1> |
| 54 | <form method="post" action="/todos"> |
| 55 | \(filterField(filter))<input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"\(editing == nil ? " autofocus" : "")> |
| 56 | </form> |
| 57 | </header> |
| 58 | |
| 59 | """ |
| 60 | html += body(todos: todos, filter: filter, editing: editing) |
| 61 | html += """ |
| 62 | </section> |
| 63 | <footer class="info"> |
| 64 | <p>Double-click to edit a todo</p> |
| 65 | <p>Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> · \(language)</p> |
| 66 | <p>Part of <a href="http://todomvc.com">TodoMVC</a></p> |
| 67 | </footer> |
| 68 | </body> |
| 69 | </html> |
| 70 | |
| 71 | """ |
| 72 | return html |
| 73 | } |
| 74 | |
| 75 | /// `section.main` and `footer.footer`, or nothing when the list is empty. |
| 76 | /// The page's JavaScript swaps this in after each action. |
| 77 | static func body(todos: [Todo], filter: Filter, editing: Int?) -> String { |
| 78 | guard !todos.isEmpty else { return "" } |
| 79 | let left = todos.count { !$0.completed } |
| 80 | let done = todos.count - left |
| 81 | var html = """ |
| 82 | <section class="main"> |
| 83 | <input id="toggle-all" class="toggle-all" type="checkbox"\(left == 0 ? " checked" : "")> |
| 84 | <label for="toggle-all">Mark all as complete</label> |
| 85 | <form class="inline" method="post" action="/todos/toggle-all">\(filterField(filter))<button class="nojs" data-action="toggle-all">Mark all as complete</button></form> |
| 86 | <ul class="todo-list"> |
| 87 | |
| 88 | """ |
| 89 | for todo in todos where filter.includes(todo) { |
| 90 | html += item(todo, filter: filter, editing: todo.id == editing) |
| 91 | } |
| 92 | html += """ |
| 93 | </ul> |
| 94 | </section> |
| 95 | <footer class="footer"> |
| 96 | <span class="todo-count"><strong>\(left)</strong> \(left == 1 ? "item" : "items") left</span> |
| 97 | <ul class="filters"> |
| 98 | |
| 99 | """ |
| 100 | for (f, name) in [(Filter.all, "All"), (.active, "Active"), (.completed, "Completed")] { |
| 101 | html += "<li><a\(f == filter ? " class=\"selected\"" : "") href=\"\(f.path)\">\(name)</a></li>\n" |
| 102 | } |
| 103 | html += "</ul>\n" |
| 104 | if done > 0 { |
| 105 | html += """ |
| 106 | <form class="inline" method="post" action="/todos/clear-completed">\(filterField(filter))<button class="clear-completed">Clear completed</button></form> |
| 107 | |
| 108 | """ |
| 109 | } |
| 110 | html += "</footer>\n" |
| 111 | return html |
| 112 | } |
| 113 | |
| 114 | static func item(_ todo: Todo, filter: Filter, editing: Bool) -> String { |
| 115 | let id = todo.id |
| 116 | let title = escape(todo.title) |
| 117 | var classes: [String] = [] |
| 118 | if todo.completed { classes.append("completed") } |
| 119 | if editing { classes.append("editing") } |
| 120 | let classAttr = classes.isEmpty ? "" : " class=\"\(classes.joined(separator: " "))\"" |
| 121 | let editHref = "\(filter.path)?edit=\(id)" |
| 122 | return """ |
| 123 | <li\(classAttr) data-id="\(id)"> |
| 124 | <form id="toggle-\(id)" method="post" action="/todos/\(id)/toggle" hidden>\(filterField(filter))</form> |
| 125 | <form id="destroy-\(id)" method="post" action="/todos/\(id)/destroy" hidden>\(filterField(filter))</form> |
| 126 | <div class="view"> |
| 127 | <input class="toggle" type="checkbox"\(todo.completed ? " checked" : "") aria-label="Toggle"> |
| 128 | <label>\(title)</label> |
| 129 | <button class="nojs" data-action="toggle" form="toggle-\(id)">Toggle</button> |
| 130 | <a class="nojs" data-action="edit" href="\(editHref)" title="Edit">✎</a> |
| 131 | <button class="destroy" form="destroy-\(id)" aria-label="Delete"></button> |
| 132 | </div> |
| 133 | <form method="post" action="/todos/\(id)/edit">\(filterField(filter))<input class="edit" name="title" value="\(title)" autocomplete="off"\(editing ? " autofocus" : "")></form> |
| 134 | </li> |
| 135 | |
| 136 | """ |
| 137 | } |
| 138 | |
| 139 | static func filterField(_ filter: Filter) -> String { |
| 140 | "<input type=\"hidden\" name=\"filter\" value=\"\(filter.rawValue)\">" |
| 141 | } |
| 142 | |
| 143 | /// Escapes text for use in HTML content and in double-quoted attributes. |
| 144 | static func escape(_ text: String) -> String { |
| 145 | var out = "" |
| 146 | out.reserveCapacity(text.utf8.count) |
| 147 | for c in text.unicodeScalars { |
| 148 | switch c { |
| 149 | case "&": out += "&" |
| 150 | case "<": out += "<" |
| 151 | case ">": out += ">" |
| 152 | case "\"": out += """ |
| 153 | case "'": out += "'" |
| 154 | default: out.unicodeScalars.append(c) |
| 155 | } |
| 156 | } |
| 157 | return out |
| 158 | } |
| 159 | } |
| 160 | |