╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭───────────────────────────────────────╮ │ 1 const std = @import("std"); │ │ 2 const xit = @import("xit"); │ │ 3 const rp = xit.repo; │ │ 4 +const hash = xit.hash; │ │ 5 +const evt = @import("./event.zig");│ │ 6 const ui = @import("./ui.zig"); │ │ 7 const xitui = xit.xitui; │ │ 8 +const wgt = xitui.widget; │ │ 9 const Focus = xitui.focus.Focus; │ │10 + │ │11 +const cookie_name = "haxy_user"; │ │12 │ │13 const Embed = struct { │ │14 path: []const u8, │ ╰───────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 82 .err = err, │ │ 83 }}); │ │ 84 } │ │ 80 - │ │ 81 -fn renderIndexHtml(io: std.Io, allocator: std.mem.Allocator, admin_repo_path: []const u8) ![]const u8 { │ │ 82 - const template = (findEmbed("/index.html") orelse return error.MissingIndexAsset).body; │ │ 83 - │ │ 84 - // open the admin repo to read live user/repo data. │ │ 85 - const repo_opts: rp.RepoOpts(.xit) = .{}; │ │ 86 - const Repo = rp.Repo(.xit, repo_opts); │ │ 87 - var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); │ │ 88 - defer repo.deinit(io, allocator); │ │ 89 - var page_arena = std.heap.ArenaAllocator.init(allocator); │ │ 90 - defer page_arena.deinit(); │ │ 91 - const page: ui.Page = .{ .home = try .init(repo_opts, &page_arena, &repo) }; │ │ 92 - │ │ 93 - var root = try ui.initRoot(allocator, &page); │ │ 94 - defer root.deinit(); │ │ 95 - │ │ 96 - const content = try generateHtml(allocator, &root); │ │ 97 - defer allocator.free(content); │ │ 98 - │ │ 99 - // serialize the page so the wasm side can parse it back without making │ │100 - // a second request. base64-encoded so the json can be embedded in html. │ │101 - var json: std.Io.Writer.Allocating = .init(allocator); │ │102 - defer json.deinit(); │ │103 - try std.json.Stringify.value(page, .{}, &json.writer); │ │104 - │ │105 - const b64 = std.base64.standard.Encoder; │ │106 - const json_b64 = try allocator.alloc(u8, b64.calcSize(json.written().len)); │ │107 - defer allocator.free(json_b64); │ │108 - _ = b64.encode(json_b64, json.written()); │ │ 85 │ │110 - var out: std.ArrayList(u8) = .empty; │ │111 - errdefer out.deinit(allocator); │ │112 - { │ │113 - var cursor: usize = 0; │ │114 - for (&[_]struct { needle: []const u8, replacement: []const u8 }{ │ │115 - .{ .needle = "{{{ HAXY_HTML }}}", .replacement = content }, │ │116 - .{ .needle = "{{{ HAXY_JSON }}}", .replacement = json_b64 }, │ │117 - }) |sub| { │ │118 - const idx = std.mem.indexOfPos(u8, template, cursor, sub.needle) orelse return error.MissingTemplateToken;│ │119 - try out.appendSlice(allocator, template[cursor..idx]); │ │120 - try out.appendSlice(allocator, sub.replacement); │ │121 - cursor = idx + sub.needle.len; │ │122 - } │ │123 - try out.appendSlice(allocator, template[cursor..]); │ │124 - } │ │125 - return try out.toOwnedSlice(allocator); │ │126 -} │ │127 - │ │ 86 fn handleConnection( │ │ 87 io: std.Io, │ │ 88 allocator: std.mem.Allocator, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │123 allocator: std.mem.Allocator, │ │124 admin_repo_path: []const u8, │ │125 ) !void { │ │168 - const method = if (request.head.method == .HEAD) .GET else request.head.method;│ │169 - if (method != .GET) { │ │126 + const method = request.head.method; │ │127 + const uri = try std.Uri.parseAfterScheme("", request.head.target); │ │128 + const path = uri.path.percent_encoded; │ │129 + │ │130 + if (method == .POST and std.mem.eql(u8, path, "/login")) { │ │131 + return handleLogin(io, http_server, request, allocator, admin_repo_path); │ │132 + } │ │133 + if (method == .POST and std.mem.eql(u8, path, "/logout")) { │ │134 + return handleLogout(http_server, request); │ │135 + } │ │136 + │ │137 + const get_or_head = method == .GET or method == .HEAD; │ │138 + if (!get_or_head) { │ │139 if (http_server.reader.state == .received_head) { │ │140 http_server.reader.state = .ready; │ │141 } │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────╮ │143 return; │ │144 } │ │145 │ │177 - const uri = try std.Uri.parseAfterScheme("", request.head.target);│ │178 - const path = uri.path.percent_encoded; │ │179 - │ │146 if (std.mem.eql(u8, path, "/favicon.ico")) { │ │147 if (http_server.reader.state == .received_head) { │ │148 http_server.reader.state = .ready; │ ╰───────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │149 } │ │150 try writeStaticResponse(http_server, 204, "No Content", "image/x-icon", "", true); │ │151 + return; │ │152 + } │ │153 + │ │154 + // for the index page we need to consult the session cookie before │ │155 + // consuming the request, so we don't dispatch through findEmbed here. │ │156 + if (std.mem.eql(u8, path, "/") or std.mem.eql(u8, path, "/index.html")) { │ │157 + const user_id_hex = getCookieValue(request, cookie_name); │ │158 + if (http_server.reader.state == .received_head) { │ │159 + http_server.reader.state = .ready; │ │160 + } │ │161 + const html = try renderIndexHtml(io, allocator, admin_repo_path, user_id_hex); │ │162 + defer allocator.free(html); │ │163 + try writeStaticResponse(http_server, 200, "OK", "text/html; charset=utf-8", html, method == .HEAD);│ │164 return; │ │165 } │ │166 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │174 │ │175 if (http_server.reader.state == .received_head) { │ │176 http_server.reader.state = .ready; │ │177 + } │ │178 + try writeStaticResponse(http_server, 200, "OK", embed.content_type, embed.body, method == .HEAD); │ │179 +} │ │180 + │ │181 +fn handleLogin( │ │182 + io: std.Io, │ │183 + http_server: *std.http.Server, │ │184 + request: *std.http.Server.Request, │ │185 + allocator: std.mem.Allocator, │ │186 + admin_repo_path: []const u8, │ │187 +) !void { │ │188 + var body_buf: [256]u8 = undefined; │ │189 + const reader = request.readerExpectNone(&body_buf); │ │190 + const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; │ │191 + defer allocator.free(body); │ │192 + │ │193 + const username = (try parseFormField(allocator, body, "username")) orelse try allocator.dupe(u8, ""); │ │194 + defer allocator.free(username); │ │195 + const password = (try parseFormField(allocator, body, "password")) orelse try allocator.dupe(u8, ""); │ │196 + defer allocator.free(password); │ │197 + │ │198 + const repo_opts: rp.RepoOpts(.xit) = .{}; │ │199 + const Repo = rp.Repo(.xit, repo_opts); │ │200 + var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); │ │201 + defer repo.deinit(io, allocator); │ │202 + │ │203 + var arena = std.heap.ArenaAllocator.init(allocator); │ │204 + defer arena.deinit(); │ │205 + │ │206 + const haxy_moment = try openHaxyMoment(repo_opts, &repo); │ │207 + const result = try evt.User.verifyCredentials(Repo.DB, repo_opts.hash, haxy_moment, &arena, username, password); │ │208 + │ │209 + switch (result) { │ │210 + .success => |user_id| { │ │211 + const hex = std.fmt.bytesToHex(user_id, .lower); │ │212 + try http_server.out.print( │ │213 + "HTTP/1.1 303 See Other\r\nLocation: /\r\nSet-Cookie: " ++ cookie_name ++ "={s}; Path=/; HttpOnly; SameSite=Strict\r\nContent-Length: 0\r\n\r\n",│ │214 + .{hex}, │ │215 + ); │ │216 + }, │ │217 + .unknown_user, .wrong_password => { │ │218 + try http_server.out.print( │ │219 + "HTTP/1.1 303 See Other\r\nLocation: /\r\nContent-Length: 0\r\n\r\n", │ │220 + .{}, │ │221 + ); │ │222 + }, │ │223 } │ │224 +} │ │225 │ │200 - if (std.mem.eql(u8, embed.path, "index.html")) { │ │201 - const index_html = try renderIndexHtml(io, allocator, admin_repo_path); │ │202 - defer allocator.free(index_html); │ │203 - try writeStaticResponse(http_server, 200, "OK", embed.content_type, index_html, request.head.method == .HEAD); │ │204 - } else { │ │205 - try writeStaticResponse(http_server, 200, "OK", embed.content_type, embed.body, request.head.method == .HEAD); │ │226 +fn handleLogout(http_server: *std.http.Server, request: *std.http.Server.Request) !void { │ │227 + // we don't need the body, but the http server wants the request consumed │ │228 + // before the next one (and before we write the response). │ │229 + var sink_buf: [64]u8 = undefined; │ │230 + _ = request.readerExpectNone(&sink_buf).discardRemaining() catch {}; │ │231 + │ │232 + try http_server.out.print( │ │233 + "HTTP/1.1 303 See Other\r\nLocation: /\r\nSet-Cookie: " ++ cookie_name ++ "=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0\r\nContent-Length: 0\r\n\r\n",│ │234 + .{}, │ │235 + ); │ │236 +} │ │237 + │ │238 +fn renderIndexHtml( │ │239 + io: std.Io, │ │240 + allocator: std.mem.Allocator, │ │241 + admin_repo_path: []const u8, │ │242 + user_id_hex_opt: ?[]const u8, │ │243 +) ![]const u8 { │ │244 + const template = (findEmbed("/index.html") orelse return error.MissingIndexAsset).body; │ │245 + │ │246 + // open the admin repo to read live user/repo data. │ │247 + const repo_opts: rp.RepoOpts(.xit) = .{}; │ │248 + const Repo = rp.Repo(.xit, repo_opts); │ │249 + var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); │ │250 + defer repo.deinit(io, allocator); │ │251 + var page_arena = std.heap.ArenaAllocator.init(allocator); │ │252 + defer page_arena.deinit(); │ │253 + │ │254 + var session: ui.Session = .{}; │ │255 + if (user_id_hex_opt) |hex| { │ │256 + if (decodeHexAlloc(page_arena.allocator(), hex)) |bytes| { │ │257 + session.user_id = bytes; │ │258 + } else |_| {} │ │259 + } │ │260 + │ │261 + const page: ui.Page = .{ .home = try .init(repo_opts, &page_arena, &repo, &session) }; │ │262 + var root = try ui.initRoot(allocator, &page, &session); │ │263 + defer root.deinit(); │ │264 + │ │265 + const content = try generateHtml(allocator, &root, &session); │ │266 + defer allocator.free(content); │ │267 + │ │268 + // serialize the page so the wasm side can parse it back without making │ │269 + // a second request. base64-encoded so the json can be embedded in html. │ │270 + var json: std.Io.Writer.Allocating = .init(allocator); │ │271 + defer json.deinit(); │ │272 + try std.json.Stringify.value(page, .{}, &json.writer); │ │273 + │ │274 + const b64 = std.base64.standard.Encoder; │ │275 + const json_b64 = try allocator.alloc(u8, b64.calcSize(json.written().len)); │ │276 + defer allocator.free(json_b64); │ │277 + _ = b64.encode(json_b64, json.written()); │ │278 + │ │279 + var out: std.ArrayList(u8) = .empty; │ │280 + errdefer out.deinit(allocator); │ │281 + { │ │282 + var cursor: usize = 0; │ │283 + for (&[_]struct { needle: []const u8, replacement: []const u8 }{ │ │284 + .{ .needle = "{{{ HAXY_HTML }}}", .replacement = content }, │ │285 + .{ .needle = "{{{ HAXY_JSON }}}", .replacement = json_b64 }, │ │286 + }) |sub| { │ │287 + const idx = std.mem.indexOfPos(u8, template, cursor, sub.needle) orelse return error.MissingTemplateToken; │ │288 + try out.appendSlice(allocator, template[cursor..idx]); │ │289 + try out.appendSlice(allocator, sub.replacement); │ │290 + cursor = idx + sub.needle.len; │ │291 + } │ │292 + try out.appendSlice(allocator, template[cursor..]); │ │293 } │ │294 + return try out.toOwnedSlice(allocator); │ │295 } │ │296 │ │297 fn findEmbed(request_path: []const u8) ?Embed { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │343 err.flush() catch {}; │ │344 } │ │345 │ │258 -pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget) ![]const u8 { │ │346 +pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget, session: *const ui.Session) ![]const u8 {│ │347 const grid = root.getGrid() orelse return error.MissingGrid; │ │348 const root_focus = root.getFocus(); │ │349 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │350 var out: std.ArrayList(u8) = .empty; │ │351 errdefer out.deinit(allocator); │ │352 + │ │353 + // determines the form route for any submit_button on this page. only one │ │354 + // is visible at a time: login when logged out, logout when logged in. │ │355 + const submit_url: []const u8 = if (session.user_id != null) "/logout" else "/login"; │ │356 │ │357 + // collect the text inputs in deterministic top-to-bottom, left-to-right │ │358 + // order. root_focus.children is a hash map so iterating it directly would │ │359 + // give arbitrary tab order between username / password. │ │360 + const InputEntry = struct { │ │361 + focus_id: usize, │ │362 + x: usize, │ │363 + y: usize, │ │364 + width: usize, │ │365 + is_password: bool, │ │366 + ti: *wgt.TextInput(ui.Widget), │ │367 + }; │ │368 + var inputs: std.ArrayList(InputEntry) = .empty; │ │369 + defer inputs.deinit(allocator); │ │370 + var focus_iter = root_focus.children.iterator(); │ │371 + while (focus_iter.next()) |entry| { │ │372 + const child = entry.value_ptr.*; │ │373 + const is_password = switch (child.focus.kind) { │ │374 + .text_input => false, │ │375 + .text_input_password => true, │ │376 + else => continue, │ │377 + }; │ │378 + try inputs.append(allocator, .{ │ │379 + .focus_id = entry.key_ptr.*, │ │380 + .x = child.rect.x, │ │381 + .y = child.rect.y, │ │382 + .width = child.rect.size.width, │ │383 + .is_password = is_password, │ │384 + .ti = @fieldParentPtr("focus", child.focus), │ │385 + }); │ │386 + } │ │387 + std.mem.sort(InputEntry, inputs.items, {}, struct { │ │388 + fn lt(_: void, a: InputEntry, b: InputEntry) bool { │ │389 + if (a.y != b.y) return a.y < b.y; │ │390 + return a.x < b.x; │ │391 + } │ │392 + }.lt); │ │393 + │ │394 + // emit the overlay inputs FIRST so they precede any submit-button span │ │395 + // in DOM order; with the submit button carrying tabindex="0", that puts │ │396 + // it at the end of the natural tab order — username -> password -> button. │ │397 + for (inputs.items) |entry| { │ │398 + const inner_left = entry.x + 1; │ │399 + const inner_top = entry.y + 1; │ │400 + const inner_width = if (entry.width > 2) entry.width - 2 else 0; │ │401 + │ │402 + try out.appendSlice(allocator, "<input type=\""); │ │403 + try out.appendSlice(allocator, if (entry.is_password) "password" else "text"); │ │404 + try out.appendSlice(allocator, "\" data-focus-id=\""); │ │405 + var id_buf: [32]u8 = undefined; │ │406 + try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{entry.focus_id})); │ │407 + if (entry.ti.options.name.len > 0) { │ │408 + try out.appendSlice(allocator, "\" name=\""); │ │409 + try appendEscapedHtml(allocator, &out, entry.ti.options.name); │ │410 + } │ │411 + try out.appendSlice(allocator, "\" value=\""); │ │412 + var value_buf: std.ArrayList(u8) = .empty; │ │413 + defer value_buf.deinit(allocator); │ │414 + for (entry.ti.content.items) |cp| { │ │415 + try value_buf.appendSlice(allocator, cp); │ │416 + } │ │417 + try appendEscapedHtml(allocator, &out, value_buf.items); │ │418 + try out.appendSlice(allocator, "\" style=\"left:"); │ │419 + var pos_buf: [64]u8 = undefined; │ │420 + try out.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, "{d}ch;top:{d}em;width:{d}ch;height:1em", .{ inner_left, inner_top, inner_width }));│ │421 + try out.appendSlice(allocator, "\">"); │ │422 + } │ │423 + │ │424 for (0..grid.size.height) |y| { │ │425 // wrap runs of cells belonging to a focusable widget in a span tagged │ │426 // with that widget's focus id. CSS paints them with a pointer cursor; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │431 if (cell_id != current_id) { │ │432 if (current_id != null) try out.appendSlice(allocator, "</span>"); │ │433 if (cell_id) |id| { │ │275 - var buf: [64]u8 = undefined; │ │276 - const tag = try std.fmt.bufPrint(&buf, "<span class=\"clickable\" data-focus-id=\"{}\">", .{id}); │ │434 + const kind = if (root_focus.children.get(id)) |entry| entry.focus.kind else .container; │ │435 + var buf: [256]u8 = undefined; │ │436 + // tabindex="0" on submit buttons puts them in the browser's │ │437 + // natural Tab order alongside the inputs above. │ │438 + const tag = if (kind == .submit_button) │ │439 + try std.fmt.bufPrint(&buf, "<span class=\"clickable\" data-focus-id=\"{d}\" data-action=\"submit\" data-url=\"{s}\" tabindex=\"0\">", .{ id, submit_url })│ │440 + else │ │441 + try std.fmt.bufPrint(&buf, "<span class=\"clickable\" data-focus-id=\"{d}\">", .{id}); │ │442 try out.appendSlice(allocator, tag); │ │443 } │ │444 current_id = cell_id; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │475 '"' => try out.appendSlice(allocator, "&quot;"), │ │476 '\'' => try out.appendSlice(allocator, "&#39;"), │ │477 else => try out.append(allocator, ch), │ │478 + } │ │479 + } │ │480 +} │ │481 + │ │482 +// --- helpers -------------------------------------------------------------- │ │483 + │ │484 +fn openHaxyMoment( │ │485 + comptime repo_opts: rp.RepoOpts(.xit), │ │486 + repo: *rp.Repo(.xit, repo_opts), │ │487 +) !rp.Repo(.xit, repo_opts).DB.HashMap(.read_only) { │ │488 + const DB = rp.Repo(.xit, repo_opts).DB; │ │489 + const history = try DB.ArrayList(.read_only).init(repo.core.db.rootCursor().readOnly()); │ │490 + const moment_cursor = try history.getCursor(-1) orelse return error.NotFound; │ │491 + const moment = try DB.HashMap(.read_only).init(moment_cursor); │ │492 + const last_object_id_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy-last-object-id")) orelse return error.NotFound;│ │493 + var last_object_id: [hash.byteLen(repo_opts.hash)]u8 = undefined; │ │494 + _ = try last_object_id_cursor.readBytes(&last_object_id); │ │495 + const haxy_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy")) orelse return error.NotFound; │ │496 + const haxy = try DB.ArrayList(.read_only).init(haxy_cursor); │ │497 + const haxy_moments_cursor = try haxy.getCursor(-1) orelse return error.NotFound; │ │498 + const haxy_moments = try DB.HashMap(.read_only).init(haxy_moments_cursor); │ │499 + const haxy_moment_cursor = try haxy_moments.getCursor(hash.bytesToInt(repo_opts.hash, &last_object_id)) orelse return error.NotFound;│ │500 + return try DB.HashMap(.read_only).init(haxy_moment_cursor); │ │501 +} │ │502 + │ │503 +fn getCookieValue(request: *std.http.Server.Request, name: []const u8) ?[]const u8 { │ │504 + var iter = request.iterateHeaders(); │ │505 + while (iter.next()) |header| { │ │506 + if (!std.ascii.eqlIgnoreCase(header.name, "cookie")) continue; │ │507 + var pairs = std.mem.splitScalar(u8, header.value, ';'); │ │508 + while (pairs.next()) |pair| { │ │509 + const trimmed = std.mem.trim(u8, pair, " \t"); │ │510 + const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue; │ │511 + if (std.mem.eql(u8, trimmed[0..eq], name)) { │ │512 + return trimmed[eq + 1 ..]; │ │513 + } │ │514 } │ │515 } │ │516 + return null; │ │517 } │ │518 │ │519 +fn decodeHexAlloc(allocator: std.mem.Allocator, hex: []const u8) ![]u8 { │ │520 + if (hex.len % 2 != 0) return error.InvalidHex; │ │521 + const bytes = try allocator.alloc(u8, hex.len / 2); │ │522 + errdefer allocator.free(bytes); │ │523 + _ = std.fmt.hexToBytes(bytes, hex) catch return error.InvalidHex; │ │524 + return bytes; │ │525 +} │ │526 + │ │527 +fn parseFormField(allocator: std.mem.Allocator, body: []const u8, key: []const u8) !?[]u8 { │ │528 + var iter = std.mem.splitScalar(u8, body, '&'); │ │529 + while (iter.next()) |pair| { │ │530 + const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; │ │531 + if (std.mem.eql(u8, pair[0..eq], key)) { │ │532 + return try decodeFormValue(allocator, pair[eq + 1 ..]); │ │533 + } │ │534 + } │ │535 + return null; │ │536 +} │ │537 + │ │538 +fn decodeFormValue(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { │ │539 + var out: std.ArrayList(u8) = .empty; │ │540 + errdefer out.deinit(allocator); │ │541 + var i: usize = 0; │ │542 + while (i < encoded.len) : (i += 1) { │ │543 + switch (encoded[i]) { │ │544 + '+' => try out.append(allocator, ' '), │ │545 + '%' => { │ │546 + if (i + 2 >= encoded.len) return error.InvalidPercentEncoding; │ │547 + const hi = std.fmt.charToDigit(encoded[i + 1], 16) catch return error.InvalidPercentEncoding; │ │548 + const lo = std.fmt.charToDigit(encoded[i + 2], 16) catch return error.InvalidPercentEncoding; │ │549 + try out.append(allocator, hi * 16 + lo); │ │550 + i += 2; │ │551 + }, │ │552 + else => try out.append(allocator, encoded[i]), │ │553 + } │ │554 + } │ │555 + return try out.toOwnedSlice(allocator); │ │556 +} │ │557 + │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯