diff options
author | sadbeast <sadbeast@sadbeast.com> | 2024-07-16 18:16:29 -0700 |
---|---|---|
committer | sadbeast <sadbeast@sadbeast.com> | 2024-10-05 16:40:55 -0700 |
commit | 6bd24af2ffbea91db1b10a5d5258980ce2068c7f (patch) | |
tree | 66634833f2d45260be5fcaf9111400eda12f03cc /src/web | |
download | teamdraft-main.tar.gz teamdraft-main.tar.bz2 |
Diffstat (limited to 'src/web')
-rw-r--r-- | src/web/Cookie.zig | 418 | ||||
-rw-r--r-- | src/web/handler.zig | 0 | ||||
-rw-r--r-- | src/web/middleware/Logger.zig | 51 | ||||
-rw-r--r-- | src/web/web.zig | 241 |
4 files changed, 710 insertions, 0 deletions
diff --git a/src/web/Cookie.zig b/src/web/Cookie.zig new file mode 100644 index 0000000..c44bbd2 --- /dev/null +++ b/src/web/Cookie.zig @@ -0,0 +1,418 @@ +const std = @import("std"); + +allocator: std.mem.Allocator, +cookie_string: []const u8, +cookies: std.StringArrayHashMap(*Cookie), +modified: bool = false, +arena: std.heap.ArenaAllocator, + +const Self = @This(); + +const SameSite = enum { strict, lax, none }; +pub const CookieOptions = struct { + domain: []const u8 = "localhost", + path: []const u8 = "/", + same_site: ?SameSite = null, + secure: bool = false, + expires: ?i64 = null, // if used, set to time in seconds to be added to std.time.timestamp() + http_only: bool = false, + max_age: ?i64 = null, + partitioned: bool = false, +}; + +pub const Cookie = struct { + name: []const u8, + value: []const u8, + domain: ?[]const u8 = null, + path: ?[]const u8 = null, + same_site: ?SameSite = null, + secure: ?bool = null, + expires: ?i64 = null, // if used, set to time in seconds to be added to std.time.timestamp() + http_only: ?bool = null, + max_age: ?i64 = null, + partitioned: ?bool = null, + + /// Build a cookie string. + pub fn bufPrint(self: Cookie, buf: *[4096]u8) ![]const u8 { + var options: CookieOptions = .{}; + inline for (std.meta.fields(CookieOptions)) |field| { + @field(options, field.name) = @field(self, field.name) orelse @field(options, field.name); + } + + // secure is required if samesite is set to none + const require_secure = if (options.same_site) |same_site| same_site == .none else false; + + var stream = std.io.fixedBufferStream(buf); + const writer = stream.writer(); + + try writer.print("{s}={s}; path={s}; domain={s};", .{ + self.name, + self.value, + options.path, + options.domain, + }); + + if (options.same_site) |same_site| try writer.print(" SameSite={s};", .{@tagName(same_site)}); + if (options.secure or require_secure) try writer.writeAll(" Secure;"); + if (options.expires) |expires| try writer.print(" Expires={d};", .{std.time.timestamp() + expires}); + if (options.max_age) |max_age| try writer.print(" Max-Age={d};", .{max_age}); + if (options.http_only) try writer.writeAll(" HttpOnly;"); + if (options.partitioned) try writer.writeAll(" Partitioned;"); + + return stream.getWritten(); + } + + pub fn applyFlag(self: *Cookie, allocator: std.mem.Allocator, flag: Flag) !void { + switch (flag) { + .domain => |domain| self.domain = try allocator.dupe(u8, domain), + .path => |path| self.path = try allocator.dupe(u8, path), + .same_site => |same_site| self.same_site = same_site, + .secure => |secure| self.secure = secure, + .expires => |expires| self.expires = expires, + .http_only => |http_only| self.http_only = http_only, + .max_age => |max_age| self.max_age = max_age, + .partitioned => |partitioned| self.partitioned = partitioned, + } + } +}; + +pub fn init(allocator: std.mem.Allocator, cookie_string: []const u8) Self { + return .{ + .allocator = allocator, + .cookie_string = cookie_string, + .cookies = std.StringArrayHashMap(*Cookie).init(allocator), + .arena = std.heap.ArenaAllocator.init(allocator), + }; +} + +pub fn deinit(self: *Self) void { + var it = self.cookies.iterator(); + while (it.next()) |item| { + self.allocator.free(item.key_ptr.*); + self.allocator.free(item.value_ptr.*.value); + self.allocator.destroy(item.value_ptr.*); + } + self.cookies.deinit(); + self.arena.deinit(); +} + +pub fn get(self: *Self, key: []const u8) ?*Cookie { + return self.cookies.get(key); +} + +pub fn put(self: *Self, cookie: Cookie) !void { + self.modified = true; + + if (self.cookies.fetchSwapRemove(cookie.name)) |entry| { + self.allocator.free(entry.key); + self.allocator.free(entry.value.value); + self.allocator.destroy(entry.value); + } + const key = try self.allocator.dupe(u8, cookie.name); + const ptr = try self.allocator.create(Cookie); + ptr.* = cookie; + ptr.name = key; + ptr.value = try self.allocator.dupe(u8, cookie.value); + try self.cookies.put(key, ptr); +} + +pub const HeaderIterator = struct { + allocator: std.mem.Allocator, + cookies_iterator: std.StringArrayHashMap(*Cookie).Iterator, + buf: *[4096]u8, + + pub fn init(allocator: std.mem.Allocator, cookies: *Self, buf: *[4096]u8) HeaderIterator { + return .{ .allocator = allocator, .cookies_iterator = cookies.cookies.iterator(), .buf = buf }; + } + + pub fn next(self: *HeaderIterator) !?[]const u8 { + if (self.cookies_iterator.next()) |entry| { + const cookie = entry.value_ptr.*; + return try cookie.bufPrint(self.buf); + } else { + return null; + } + } +}; + +pub fn headerIterator(self: *Self, buf: *[4096]u8) HeaderIterator { + return HeaderIterator.init(self.allocator, self, buf); +} + +// https://datatracker.ietf.org/doc/html/rfc6265#section-4.2.1 +// cookie-header = "Cookie:" OWS cookie-string OWS +// cookie-string = cookie-pair *( ";" SP cookie-pair ) +pub fn parse(self: *Self) !void { + var key_buf = std.ArrayList(u8).init(self.allocator); + var value_buf = std.ArrayList(u8).init(self.allocator); + var key_terminated = false; + var value_started = false; + var cookie_buf = std.ArrayList(Cookie).init(self.allocator); + + defer key_buf.deinit(); + defer value_buf.deinit(); + defer cookie_buf.deinit(); + defer self.modified = false; + + for (self.cookie_string, 0..) |char, index| { + if (char == '=') { + key_terminated = true; + continue; + } + + if (char == ';' or index == self.cookie_string.len - 1) { + if (char != ';') try value_buf.append(char); + if (parseFlag(key_buf.items, value_buf.items)) |flag| { + for (cookie_buf.items) |*cookie| try cookie.applyFlag(self.arena.allocator(), flag); + } else { + try cookie_buf.append(.{ + .name = try self.arena.allocator().dupe(u8, key_buf.items), + .value = try self.arena.allocator().dupe(u8, value_buf.items), + }); + } + key_buf.clearAndFree(); + value_buf.clearAndFree(); + value_started = false; + key_terminated = false; + continue; + } + + if (!key_terminated and char == ' ') continue; + + if (!key_terminated) { + try key_buf.append(char); + continue; + } + + if (char == ' ' and !value_started) continue; + if (char != ' ' and !value_started) value_started = true; + + if (key_terminated and value_started) { + try value_buf.append(char); + continue; + } + + return error.JetzigInvalidCookieHeader; + } + + for (cookie_buf.items) |cookie| try self.put(cookie); +} + +const Flag = union(enum) { + domain: []const u8, + path: []const u8, + same_site: SameSite, + secure: bool, + expires: i64, + max_age: i64, + http_only: bool, + partitioned: bool, +}; + +fn parseFlag(key: []const u8, value: []const u8) ?Flag { + if (key.len > 64) return null; + if (value.len > 64) return null; + + var key_buf: [64]u8 = undefined; + + const normalized_key = std.ascii.lowerString(&key_buf, strip(key)); + const normalized_value = strip(value); + + if (std.mem.eql(u8, normalized_key, "domain")) { + return .{ .domain = normalized_value }; + } else if (std.mem.eql(u8, normalized_key, "path")) { + return .{ .path = normalized_value }; + } else if (std.mem.eql(u8, normalized_key, "samesite")) { + return if (std.mem.eql(u8, normalized_value, "strict")) + .{ .same_site = .strict } + else if (std.mem.eql(u8, normalized_value, "lax")) + .{ .same_site = .lax } + else + .{ .same_site = .none }; + } else if (std.mem.eql(u8, normalized_key, "secure")) { + return .{ .secure = true }; + } else if (std.mem.eql(u8, normalized_key, "httponly")) { + return .{ .http_only = true }; + } else if (std.mem.eql(u8, normalized_key, "partitioned")) { + return .{ .partitioned = true }; + } else if (std.mem.eql(u8, normalized_key, "expires")) { + return .{ .expires = std.fmt.parseInt(i64, normalized_value, 10) catch return null }; + } else if (std.mem.eql(u8, normalized_key, "max-age")) { + return .{ .max_age = std.fmt.parseInt(i64, normalized_value, 10) catch return null }; + } else { + return null; + } +} + +inline fn strip(input: []const u8) []const u8 { + return std.mem.trim(u8, input, &std.ascii.whitespace); +} + +test "basic cookie string" { + const allocator = std.testing.allocator; + var cookies = Self.init(allocator, "foo=bar; baz=qux;"); + defer cookies.deinit(); + try cookies.parse(); + try std.testing.expectEqualStrings("bar", cookies.get("foo").?.value); + try std.testing.expectEqualStrings("qux", cookies.get("baz").?.value); +} + +test "empty cookie string" { + const allocator = std.testing.allocator; + var cookies = Self.init(allocator, ""); + defer cookies.deinit(); + try cookies.parse(); +} + +test "cookie string with irregular spaces" { + const allocator = std.testing.allocator; + var cookies = Self.init(allocator, "foo= bar; baz= qux;"); + defer cookies.deinit(); + try cookies.parse(); + try std.testing.expectEqualStrings("bar", cookies.get("foo").?.value); + try std.testing.expectEqualStrings("qux", cookies.get("baz").?.value); +} + +test "headerIterator" { + const allocator = std.testing.allocator; + var buf = std.ArrayList(u8).init(allocator); + defer buf.deinit(); + + const writer = buf.writer(); + + var cookies = Self.init(allocator, "foo=bar; baz=qux;"); + defer cookies.deinit(); + try cookies.parse(); + + var it_buf: [4096]u8 = undefined; + var it = cookies.headerIterator(&it_buf); + while (try it.next()) |*header| { + try writer.writeAll(header.*); + try writer.writeAll("\n"); + } + + try std.testing.expectEqualStrings( + \\foo=bar; path=/; domain=localhost; + \\baz=qux; path=/; domain=localhost; + \\ + , buf.items); +} + +test "modified" { + const allocator = std.testing.allocator; + var cookies = Self.init(allocator, "foo=bar; baz=qux;"); + defer cookies.deinit(); + + try cookies.parse(); + try std.testing.expect(cookies.modified == false); + + try cookies.put(.{ .name = "quux", .value = "corge" }); + try std.testing.expect(cookies.modified == true); +} + +test "domain=example.com" { + const allocator = std.testing.allocator; + var cookies = Self.init(allocator, "foo=bar; baz=qux; Domain=example.com;"); + defer cookies.deinit(); + + try cookies.parse(); + const cookie = cookies.get("foo").?; + try std.testing.expectEqualStrings(cookie.domain.?, "example.com"); +} + +test "path=/example_path" { + const allocator = std.testing.allocator; + var cookies = Self.init(allocator, "foo=bar; baz=qux; Path=/example_path;"); + defer cookies.deinit(); + + try cookies.parse(); + const cookie = cookies.get("foo").?; + try std.testing.expectEqualStrings(cookie.path.?, "/example_path"); +} + +test "SameSite=lax" { + const allocator = std.testing.allocator; + var cookies = Self.init(allocator, "foo=bar; baz=qux; SameSite=lax;"); + defer cookies.deinit(); + + try cookies.parse(); + const cookie = cookies.get("foo").?; + try std.testing.expect(cookie.same_site == .lax); +} + +test "SameSite=none" { + const allocator = std.testing.allocator; + var cookies = Self.init(allocator, "foo=bar; baz=qux; SameSite=none;"); + defer cookies.deinit(); + + try cookies.parse(); + const cookie = cookies.get("foo").?; + try std.testing.expect(cookie.same_site == .none); +} + +test "SameSite=strict" { + const allocator = std.testing.allocator; + var cookies = Self.init(allocator, "foo=bar; baz=qux; SameSite=strict;"); + defer cookies.deinit(); + + try cookies.parse(); + const cookie = cookies.get("foo").?; + try std.testing.expect(cookie.same_site == .strict); +} + +test "Secure" { + const allocator = std.testing.allocator; + var cookies = Self.init(allocator, "foo=bar; baz=qux; Secure;"); + defer cookies.deinit(); + + try cookies.parse(); + const cookie = cookies.get("foo").?; + try std.testing.expect(cookie.secure.?); +} + +test "Partitioned" { + const allocator = std.testing.allocator; + var cookies = Self.init(allocator, "foo=bar; baz=qux; Partitioned;"); + defer cookies.deinit(); + + try cookies.parse(); + const cookie = cookies.get("foo").?; + try std.testing.expect(cookie.partitioned.?); +} + +test "Max-Age" { + const allocator = std.testing.allocator; + var cookies = Self.init(allocator, "foo=bar; baz=qux; Max-Age=123123123;"); + defer cookies.deinit(); + + try cookies.parse(); + const cookie = cookies.get("foo").?; + try std.testing.expect(cookie.max_age.? == 123123123); +} + +test "Expires" { + const allocator = std.testing.allocator; + var cookies = Self.init(allocator, "foo=bar; baz=qux; Expires=123123123;"); + defer cookies.deinit(); + + try cookies.parse(); + const cookie = cookies.get("foo").?; + try std.testing.expect(cookie.expires.? == 123123123); +} + +test "default flags" { + const allocator = std.testing.allocator; + var cookies = Self.init(allocator, "foo=bar; baz=qux;"); + defer cookies.deinit(); + + try cookies.parse(); + const cookie = cookies.get("foo").?; + try std.testing.expect(cookie.domain == null); + try std.testing.expect(cookie.path == null); + try std.testing.expect(cookie.same_site == null); + try std.testing.expect(cookie.secure == null); + try std.testing.expect(cookie.expires == null); + try std.testing.expect(cookie.http_only == null); + try std.testing.expect(cookie.max_age == null); + try std.testing.expect(cookie.partitioned == null); +} diff --git a/src/web/handler.zig b/src/web/handler.zig new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/web/handler.zig diff --git a/src/web/middleware/Logger.zig b/src/web/middleware/Logger.zig new file mode 100644 index 0000000..f2b1637 --- /dev/null +++ b/src/web/middleware/Logger.zig @@ -0,0 +1,51 @@ +const Logger = @This(); + +query: bool, + +// Must define an `init` method, which will accept your Config +// Alternatively, you can define a init(config: Config, mc: httpz.MiddlewareConfig) +// here mc will give you access to the server's allocator and arena +pub fn init(config: Config) !Logger { + return .{ + .query = config.query, + }; +} + +// optionally you can define an "deinit" method +// pub fn deinit(self: *Logger) void { + +// } + +// Must define an `execute` method. `self` doesn't have to be `const`, but +// you're responsible for making your middleware thread-safe. +pub fn execute(self: *const Logger, req: *httpz.Request, res: *httpz.Response, executor: anytype) !void { + const start = std.time.microTimestamp(); + + defer { + const elapsed = std.time.microTimestamp() - start; + var logger = logz.logger().multiuse() + .stringSafe("@l", "REQ") + .stringSafe("method", @tagName(req.method)) + .int("status", res.status) + .string("path", req.url.path); + + if (self.query) { + _ = logger.string("query", req.url.query); + } + + logger.int("us", elapsed).log(); + } + + // If you don't call executor.next(), there will be no further processing of + // the request and we'll go straight to writing the response. + return executor.next(); +} + +// Must defined a pub config structure, even if it's empty +pub const Config = struct { + query: bool, +}; + +const std = @import("std"); +const httpz = @import("httpz"); +const logz = @import("logz"); diff --git a/src/web/web.zig b/src/web/web.zig new file mode 100644 index 0000000..e0882a0 --- /dev/null +++ b/src/web/web.zig @@ -0,0 +1,241 @@ +pub fn start(app: *App) !void { + const allocator = app.allocator; + + var server = try httpz.Server(*App).init(allocator, .{ + .address = "0.0.0.0", + .port = 5882, + .request = .{ + .max_form_count = 4, + }, + }, app); + defer server.deinit(); + + const router = server.router(); + { + // publicly accessible + var routes = router.group("/", .{}); + + routes.get("/", index); + routes.get("/about", about); + routes.get("/invite/:code", acceptInvite); + + routes.post("/drafts", createDraft); + routes.get("/drafts/:id", showDraft); + routes.post("/drafts/:id/pick", createPick); + } + + const http_address = try std.fmt.allocPrint(allocator, "http://{s}:{d}", .{ "0.0.0.0", 5882 }); + logz.info().ctx("http").string("address", http_address).log(); + allocator.free(http_address); + + // blocks + try server.listen(); +} + +fn index(_: *RequestContext, req: *httpz.Request, res: *httpz.Response) !void { + var data = Data.init(res.arena); + defer data.deinit(); + + const dt = try zul.DateTime.fromUnix(std.time.timestamp(), .seconds); + const tomorrow = try dt.add(1, .hours); + + var root = try data.object(); + try root.put("draft_time", try std.fmt.allocPrint(req.arena, "{s}T{s}", .{ tomorrow.date(), tomorrow.time() })); + + try renderData("index", &data, req, res); +} + +fn about(_: *RequestContext, req: *httpz.Request, res: *httpz.Response) !void { + try render("about", req, res); +} + +fn acceptInvite(ctx: *RequestContext, req: *httpz.Request, res: *httpz.Response) !void { + var data = Data.init(res.arena); + defer data.deinit(); + + if (req.params.get("code")) |code| { + var accept_invite_result = (try ctx.app.pool.row("SELECT * FROM accept_invite($1)", .{code})) orelse { + res.status = 500; + return; + }; + defer accept_invite_result.deinit() catch {}; + + const session_id = accept_invite_result.get([]const u8, 0); + const draft_id = accept_invite_result.get(i64, 1); + res.header("Set-Cookie", try std.fmt.allocPrint(res.arena, "s={s}; Path=/", .{session_id})); + res.header("Location", try std.fmt.allocPrint(res.arena, "/drafts/{}", .{draft_id})); + + res.status = 302; + } +} + +fn createPick(ctx: *RequestContext, req: *httpz.Request, res: *httpz.Response) !void { + const query = try req.query(); + if (query.get("team_id")) |team_id| { + if (req.params.get("id")) |draft_id| { + _ = try ctx.app.pool.exec("CALL auto_draft($1)", .{draft_id}); + _ = try ctx.app.pool.exec("INSERT INTO picks (draft_user_id, draft_id, team_id) VALUES (current_picker_id($1), $1, $2)", .{ draft_id, team_id }); + + res.header("Location", try std.fmt.allocPrint(res.arena, "/drafts/{s}", .{draft_id})); + res.header("HX-Location", try std.fmt.allocPrint(res.arena, "/drafts/{s}", .{draft_id})); + + res.status = 302; + } + } +} + +fn createDraft(ctx: *RequestContext, req: *httpz.Request, res: *httpz.Response) !void { + const formData = try req.formData(); + + if (formData.get("player1")) |player1| { + logz.info().string("player1", player1).log(); + const player2 = formData.get("player2") orelse "Player 2"; + var create_draft_result: pg.QueryRow = undefined; + + if (formData.get("draft")) |_| { + if (formData.get("draft_time")) |draft_time| { + logz.info().string("draft_time", draft_time).log(); + create_draft_result = (try ctx.app.pool.row("SELECT * FROM create_draft(1, $1, $2, $3)", .{ player1, player2, draft_time })) orelse { + res.status = 500; + return; + }; + } + } else { + create_draft_result = (try ctx.app.pool.row("SELECT * FROM create_draft(1, $1, $2, null)", .{ player1, player2 })) orelse { + res.status = 500; + return; + }; + } + defer create_draft_result.deinit() catch {}; + + const session_id = create_draft_result.get([]const u8, 0); + const draft_id = create_draft_result.get(i64, 1); + res.header("Set-Cookie", try std.fmt.allocPrint(res.arena, "s={s}; Path=/", .{session_id})); + res.header("Location", try std.fmt.allocPrint(res.arena, "/drafts/{}", .{draft_id})); + + res.status = 302; + return; + } + + var data = Data.init(res.arena); + defer data.deinit(); + if (formData.get("draft")) |_| { + var root = try data.root(.object); + try root.put("draft", true); + } + try renderData("index", &data, req, res); +} + +pub const Team = struct { + team_id: i32, + rank: ?i16, + name: []const u8, + picked: bool, + pick_user: ?[]const u8, +}; + +pub const DraftInfo = struct { + draft_id: i64, + current_player_id: ?i64, + current_player_name: ?[]const u8, + draft_status_id: i16, + round_time_remaining: ?i32, + message: ?[]const u8, + can_pick: bool, +}; + +fn showDraft(ctx: *RequestContext, req: *httpz.Request, res: *httpz.Response) !void { + if (req.params.get("id")) |draft_id| { + var draft_count = (try ctx.app.pool.row("SELECT count(1) FROM drafts WHERE draft_id = $1", .{draft_id})) orelse @panic("oh no"); + defer draft_count.deinit() catch {}; + if (draft_count.get(i64, 0) == 0) { + return ctx.app.notFound(req, res); + } + + var data = Data.init(res.arena); + var root = try data.root(.object); + + _ = try ctx.app.pool.exec("CALL auto_draft($1)", .{draft_id}); + + const picks_query = "SELECT * FROM current_draft_picks($1)"; + var picks_result = try ctx.app.pool.query(picks_query, .{draft_id}); + defer picks_result.deinit(); + + var teams = try data.array(); + while (try picks_result.next()) |row| { + const team = try row.to(Team, .{}); + var team_data = try data.object(); + try team_data.put("name", team.name); + try team_data.put("pick_user", team.pick_user); + try team_data.put("picked", team.picked); + try team_data.put("rank", team.rank); + try team_data.put("id", team.team_id); + try teams.append(team_data); + } + try root.put("teams", teams); + + var draft_info: DraftInfo = undefined; + const current_picker_query = + \\SELECT * FROM draft_info WHERE draft_id = $1 + ; + var row = try ctx.app.pool.row(current_picker_query, .{draft_id}); + if (row) |r| { + // defer r.denit(); + draft_info = try r.to(DraftInfo, .{}); + var can_pick = false; + if (draft_info.current_player_id) |current_player_id| { + if (ctx.user) |user| { + can_pick = draft_info.can_pick and current_player_id == user.id; + } + } + try root.put("can_pick", can_pick); + try root.put("running", draft_info.draft_status_id == 2); + try root.put("draft_id", draft_info.draft_id); + try root.put("message", draft_info.message); + try root.put("round_time_remaining", draft_info.round_time_remaining); + try root.put("current_picker", draft_info.current_player_name); + } + + const code_query = + \\SELECT code FROM draft_user_invites + \\ JOIN draft_users USING(draft_user_id) + \\ WHERE draft_id = $1 + ; + var code_result = (try ctx.app.pool.row(code_query, .{draft_id})) orelse @panic("oh no"); + const code = code_result.get([]const u8, 0); + defer code_result.deinit() catch {}; + try root.put("code", code); + + defer data.deinit(); + + try renderData("draft", &data, req, res); + + try row.?.deinit(); + } +} + +pub fn render(template_name: []const u8, req: *httpz.Request, res: *httpz.Response) !void { + var data = Data.init(res.arena); + defer data.deinit(); + try renderData(template_name, &data, req, res); +} + +pub fn renderData(template_name: []const u8, data: *Data, req: *httpz.Request, res: *httpz.Response) !void { + if (zmpl.find(template_name)) |template| { + res.body = try template.renderWithOptions(data, .{ .layout = if (req.header("hx-request")) |_| zmpl.find("body") else zmpl.find("layout") }); + } +} + +const App = @import("../App.zig"); +const RequestContext = @import("../RequestContext.zig"); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const config = @import("config"); + +const httpz = @import("httpz"); +const logz = @import("logz"); +const pg = @import("pg"); +const zmpl = @import("zmpl"); +const zul = @import("zul"); +const Data = zmpl.Data; |