diff --git a/.gitignore b/.gitignore index 7d38e30..05a3a49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,4 @@ -TODO.md -main -get/ zig-out/ zig-cache/ *.core -src/app/views/**/*.compiled.zig -archive.tar.gz static/ diff --git a/archive.tar.gz b/archive.tar.gz new file mode 100644 index 0000000..b288267 Binary files /dev/null and b/archive.tar.gz differ diff --git a/build.zig b/build.zig index 0902c8f..d5c92fc 100644 --- a/build.zig +++ b/build.zig @@ -22,7 +22,7 @@ pub fn build(b: *std.Build) !void { const mime_module = try GenerateMimeTypes.generateMimeModule(b); - b.installArtifact(lib); + const zig_args_dep = b.dependency("args", .{ .target = target, .optimize = optimize }); const jetzig_module = b.addModule("jetzig", .{ .root_source_file = .{ .path = "src/jetzig.zig" } }); jetzig_module.addImport("mime_types", mime_module); @@ -39,6 +39,7 @@ pub fn build(b: *std.Build) !void { lib.root_module.addImport("zmpl", zmpl_dep.module("zmpl")); jetzig_module.addImport("zmpl", zmpl_dep.module("zmpl")); + lib.root_module.addImport("args", zig_args_dep.module("args")); // This is the way to make it look nice in the zig build script // If we would do it the other way around, we would have to do diff --git a/build.zig.zon b/build.zig.zon index 6a6f26e..489696b 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,8 +3,12 @@ .version = "0.0.0", .dependencies = .{ .zmpl = .{ - .url = "https://github.com/jetzig-framework/zmpl/archive/aa7147f8a52d927dce6cdd4f5b3bb2de5080f28c.tar.gz", - .hash = "12203d262b39b2328adb981e41c5127507f3d47e977c1a4e69a96688a4213b986d04", + .url = "https://github.com/jetzig-framework/zmpl/archive/ed99a1604b37fb05b0f5843a3288588f3dfe2e63.tar.gz", + .hash = "1220771fe742fc620872051b92082d370549ed857e5a93ae43f92c5767ca2aaf42b1", + }, + .args = .{ + .url = "https://github.com/MasterQ32/zig-args/archive/89f18a104d9c13763b90e97d6b4ce133da8a3e2b.tar.gz", + .hash = "12203ded54c85878eea7f12744066dcb4397177395ac49a7b2aa365bf6047b623829", }, }, diff --git a/cli/build.zig b/cli/build.zig new file mode 100644 index 0000000..051e610 --- /dev/null +++ b/cli/build.zig @@ -0,0 +1,33 @@ +const std = @import("std"); + +const compile = @import("compile.zig"); + +pub fn build(b: *std.Build) !void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "jetzig", + .root_source_file = .{ .path = "cli.zig" }, + .target = target, + .optimize = optimize, + }); + + const zig_args_dep = b.dependency("args", .{ .target = target, .optimize = optimize }); + + exe.root_module.addImport("args", zig_args_dep.module("args")); + exe.root_module.addImport( + "init_data", + try compile.initDataModule(b), + ); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); +} diff --git a/cli/build.zig.zon b/cli/build.zig.zon new file mode 100644 index 0000000..9350709 --- /dev/null +++ b/cli/build.zig.zon @@ -0,0 +1,15 @@ +.{ + .name = "jetzig-cli", + .version = "0.0.0", + .minimum_zig_version = "0.12.0", + + .dependencies = .{ + .args = .{ + .url = "https://github.com/MasterQ32/zig-args/archive/89f18a104d9c13763b90e97d6b4ce133da8a3e2b.tar.gz", + .hash = "12203ded54c85878eea7f12744066dcb4397177395ac49a7b2aa365bf6047b623829", + }, + }, + .paths = .{ + "", + }, +} diff --git a/cli/cli.zig b/cli/cli.zig new file mode 100644 index 0000000..65ef87d --- /dev/null +++ b/cli/cli.zig @@ -0,0 +1,60 @@ +const std = @import("std"); +const args = @import("args"); +const init = @import("init.zig"); + +const Options = struct { + help: bool = false, + + pub const shorthands = .{ + .h = "help", + }; + + pub const meta = .{ + .option_docs = .{ + .init = "Initialize a new project", + .help = "Print help and exit", + }, + }; +}; + +const Verb = union(enum) { + init: init.Options, +}; + +/// Main entrypoint for `jetzig` executable. Parses command line args and generates a new +/// project, scaffolding, etc. +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + defer std.debug.assert(gpa.deinit() == .ok); + + const options = try args.parseWithVerbForCurrentProcess(Options, Verb, allocator, .print); + defer options.deinit(); + + const writer = std.io.getStdErr().writer(); + + if (options.verb) |verb| { + switch (verb) { + .init => |opts| return init.run( + allocator, + opts, + writer, + options.positionals, + .{ .help = options.options.help }, + ), + } + } + + if (options.options.help) { + try args.printHelp(Options, "jetzig", writer); + try writer.writeAll( + \\ + \\Commands: + \\ + \\ init Initialize a new project. + \\ + \\ Pass --help to any command for more information, e.g. `jetzig init --help` + \\ + ); + } +} diff --git a/cli/compile.zig b/cli/compile.zig new file mode 100644 index 0000000..b1bb2ef --- /dev/null +++ b/cli/compile.zig @@ -0,0 +1,71 @@ +const std = @import("std"); + +fn base64Encode(allocator: std.mem.Allocator, input: []const u8) []const u8 { + const encoder = std.base64.Base64Encoder.init( + std.base64.url_safe_no_pad.alphabet_chars, + std.base64.url_safe_no_pad.pad_char, + ); + const size = encoder.calcSize(input.len); + const ptr = allocator.alloc(u8, size) catch @panic("OOM"); + _ = encoder.encode(ptr, input); + return ptr; +} + +pub fn initDataModule(build: *std.Build) !*std.Build.Module { + const root_path = build.pathFromRoot(".."); + + var buf = std.ArrayList(u8).init(build.allocator); + defer buf.deinit(); + + const writer = buf.writer(); + + const paths = .{ + "demo/build.zig", + "demo/src/main.zig", + "demo/src/app/middleware/DemoMiddleware.zig", + "demo/src/app/views/init.zig", + "demo/src/app/views/init/index.zmpl", + "demo/src/app/views/init/_content.zmpl", + "demo/public/jetzig.png", + "demo/public/zmpl.png", + "demo/public/favicon.ico", + "demo/public/styles.css", + ".gitignore", + }; + + try writer.writeAll( + \\pub const init_data = .{ + \\ + ); + + var dir = try std.fs.openDirAbsolute(root_path, .{}); + defer dir.close(); + + inline for (paths) |path| { + const stat = try dir.statFile(path); + const encoded = base64Encode( + build.allocator, + try dir.readFileAlloc(build.allocator, path, stat.size), + ); + defer build.allocator.free(encoded); + + const output = try std.fmt.allocPrint( + build.allocator, + \\.{{ .path = "{s}", .data = "{s}" }}, + , + .{ path, encoded }, + ); + defer build.allocator.free(output); + + try writer.writeAll(output); + } + + try writer.writeAll( + \\}; + \\ + ); + + const write_files = build.addWriteFiles(); + const init_data_source = write_files.add("init_data.zig", buf.items); + return build.createModule(.{ .root_source_file = init_data_source }); +} diff --git a/cli/init.zig b/cli/init.zig new file mode 100644 index 0000000..2be0815 --- /dev/null +++ b/cli/init.zig @@ -0,0 +1,427 @@ +const std = @import("std"); +const args = @import("args"); +const init_data = @import("init_data").init_data; + +fn base64Decode(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { + const decoder = std.base64.Base64Decoder.init( + std.base64.url_safe_no_pad.alphabet_chars, + std.base64.url_safe_no_pad.pad_char, + ); + const size = try decoder.calcSizeForSlice(input); + const ptr = try allocator.alloc(u8, size); + try decoder.decode(ptr, input); + return ptr; +} + +pub const Options = struct { + path: ?[]const u8 = null, + + pub const shorthands = .{ + .p = "path", + }; + + pub const meta = .{ + .usage_summary = "[--path PATH]", + .full_text = + \\Initializes a new Jetzig project in the current directory or attempts to + \\create a new directory specified by PATH + \\ + \\Creates build.zig, build.zig.zon, src/main.zig, and an example view with a template. + \\ + \\Run `zig build run` to launch a development server when complete. + , + .option_docs = .{ + .path = "Set the output path relative to the current directory (default: current directory)", + }, + }; +}; + +/// Run the `jetzig init` command. +pub fn run( + allocator: std.mem.Allocator, + options: Options, + writer: anytype, + positionals: [][]const u8, + other_options: struct { help: bool }, +) !void { + _ = options; + var install_path: ?[]const u8 = null; + + for (positionals) |arg| { + if (install_path != null) { + std.debug.print("Unexpected positional argument: {s}\n", .{arg}); + return error.JetzigUnexpectedPositionalArgumentsError; + } + install_path = arg; + } + + const github_url = try githubUrl(allocator); + defer allocator.free(github_url); + + if (other_options.help) { + try args.printHelp(Options, "jetzig init", writer); + return; + } + + var install_dir: std.fs.Dir = undefined; + defer install_dir.close(); + + var project_name: []const u8 = undefined; + defer allocator.free(project_name); + + if (install_path) |path| { + install_dir = try std.fs.cwd().makeOpenPath(path, .{}); + project_name = try allocator.dupe(u8, std.fs.path.basename(path)); + } else { + const cwd_realpath = try std.fs.cwd().realpathAlloc(allocator, "."); + defer allocator.free(cwd_realpath); + + const default_project_name = std.fs.path.basename(cwd_realpath); + project_name = try promptInput(allocator, "Project name", .{ .default = default_project_name }); + const sub_path = if (std.mem.eql(u8, project_name, default_project_name)) "" else project_name; + + const default_install_path = try std.fs.path.join( + allocator, + &[_][]const u8{ cwd_realpath, sub_path }, + ); + defer allocator.free(default_install_path); + + const input_install_path = try promptInput( + allocator, + "Install path", + .{ .default = default_install_path }, + ); + defer allocator.free(input_install_path); + install_dir = try std.fs.cwd().makeOpenPath(input_install_path, .{}); + } + + const real_path = try install_dir.realpathAlloc(allocator, "."); + defer allocator.free(real_path); + + const output = try std.fmt.allocPrint(allocator, "Creating new project in {s}\n\n", .{real_path}); + defer allocator.free(output); + try writer.writeAll(output); + + try copySourceFile( + allocator, + install_dir, + "demo/build.zig", + "build.zig", + &[_]Replace{.{ .from = "jetzig-demo", .to = project_name }}, + ); + + try copySourceFile( + allocator, + install_dir, + "demo/src/main.zig", + "src/main.zig", + null, + ); + + try copySourceFile( + allocator, + install_dir, + "demo/src/app/middleware/DemoMiddleware.zig", + "src/app/middleware/DemoMiddleware.zig", + null, + ); + + try copySourceFile( + allocator, + install_dir, + "demo/src/app/views/init.zig", + "src/app/views/root.zig", + null, + ); + + try copySourceFile( + allocator, + install_dir, + "demo/src/app/views/init/index.zmpl", + "src/app/views/root/index.zmpl", + &[_]Replace{ + .{ .from = "init/", .to = "root/" }, + }, + ); + + try copySourceFile( + allocator, + install_dir, + "demo/src/app/views/init/_content.zmpl", + "src/app/views/root/_content.zmpl", + null, + ); + + try copySourceFile( + allocator, + install_dir, + "demo/public/jetzig.png", + "public/jetzig.png", + null, + ); + + try copySourceFile( + allocator, + install_dir, + "demo/public/zmpl.png", + "public/zmpl.png", + null, + ); + + try copySourceFile( + allocator, + install_dir, + "demo/public/favicon.ico", + "public/favicon.ico", + null, + ); + + try copySourceFile( + allocator, + install_dir, + "demo/public/styles.css", + "public/styles.css", + null, + ); + + try copySourceFile( + allocator, + install_dir, + ".gitignore", + ".gitignore", + null, + ); + + try runCommand(allocator, install_dir, &[_][]const u8{ + "zig", + "fetch", + "--save", + github_url, + }); + + // TODO: Use arg or interactive prompt to do Git setup in net project, default to no. + // const git_setup = false; + // if (git_setup) try gitSetup(allocator, install_dir); + + std.debug.print( + \\ + \\Setup complete! ✈️ 🦎 + \\ + \\Launch your new application: + \\ + \\ $ cd {s} + \\ + \\ $ zig build run + \\ + \\And then browse to http://localhost:8080/ + \\ + \\ + , .{real_path}); +} + +fn runCommand(allocator: std.mem.Allocator, install_dir: std.fs.Dir, argv: []const []const u8) !void { + const result = try std.process.Child.run(.{ .allocator = allocator, .argv = argv, .cwd_dir = install_dir }); + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + + const command = try std.mem.join(allocator, " ", argv); + defer allocator.free(command); + + std.debug.print("[exec] {s}", .{command}); + + if (result.term.Exited != 0) { + printFailure(); + std.debug.print( + \\ + \\Error running command: {s} + \\ + \\[stdout]: + \\ + \\{s} + \\ + \\[stderr]: + \\ + \\{s} + \\ + , .{ command, result.stdout, result.stderr }); + return error.JetzigRunCommandError; + } else { + printSuccess(); + } +} + +const Replace = struct { + from: []const u8, + to: []const u8, +}; + +fn copySourceFile( + allocator: std.mem.Allocator, + install_dir: std.fs.Dir, + src: []const u8, + dest: []const u8, + replace: ?[]const Replace, +) !void { + std.debug.print("[create] {s}", .{dest}); + + var content: []const u8 = undefined; + if (replace) |capture| { + const initial = readSourceFile(allocator, src) catch |err| { + printFailure(); + return err; + }; + defer allocator.free(initial); + for (capture) |item| { + content = try std.mem.replaceOwned(u8, allocator, initial, item.from, item.to); + } + } else { + content = readSourceFile(allocator, src) catch |err| { + printFailure(); + return err; + }; + } + defer allocator.free(content); + + writeSourceFile(install_dir, dest, content) catch |err| { + printFailure(); + return err; + }; + printSuccess(); +} + +// Read a file from Jetzig source code. +fn readSourceFile(allocator: std.mem.Allocator, path: []const u8) ![]const u8 { + inline for (init_data) |file| { + if (std.mem.eql(u8, path, file.path)) return try base64Decode(allocator, file.data); + } + return error.SourceFileNotFound; +} + +// Write a file to the new project's directory. +fn writeSourceFile(install_dir: std.fs.Dir, path: []const u8, content: []const u8) !void { + // TODO: Detect presence and ask for confirmation if necessary. + if (std.fs.path.dirname(path)) |dirname| { + var dir = try install_dir.makeOpenPath(dirname, .{}); + defer dir.close(); + + const file = try dir.createFile(std.fs.path.basename(path), .{ .truncate = true }); + defer file.close(); + + try file.writeAll(content); + } else { + const file = try install_dir.createFile(path, .{ .truncate = true }); + defer file.close(); + + try file.writeAll(content); + } +} + +// Generate a full GitHub URL for passing to `zig fetch`. +fn githubUrl(allocator: std.mem.Allocator) ![]const u8 { + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + const url = "https://api.github.com/repos/jetzig-framework/jetzig/branches/main"; + const extra_headers = &[_]std.http.Header{.{ .name = "X-GitHub-Api-Version", .value = "2022-11-28" }}; + + var response_storage = std.ArrayList(u8).init(allocator); + defer response_storage.deinit(); + + const fetch_result = try client.fetch(.{ + .location = .{ .url = url }, + .extra_headers = extra_headers, + .response_storage = .{ .dynamic = &response_storage }, + }); + + if (fetch_result.status != .ok) { + std.debug.print("Error fetching from GitHub: {s}\n", .{url}); + return error.JetzigGitHubFetchError; + } + + const parsed_response = try std.json.parseFromSlice( + struct { commit: struct { sha: []const u8 } }, + allocator, + response_storage.items, + .{ .ignore_unknown_fields = true }, + ); + defer parsed_response.deinit(); + + return try std.mem.concat( + allocator, + u8, + &[_][]const u8{ + "https://github.com/jetzig-framework/jetzig/archive/", + parsed_response.value.commit.sha, + ".tar.gz", + }, + ); +} + +// Prompt a user for input and return the result. Accepts an optional default value. +fn promptInput( + allocator: std.mem.Allocator, + prompt: []const u8, + options: struct { default: ?[]const u8 }, +) ![]const u8 { + const stdin = std.io.getStdIn(); + const reader = stdin.reader(); + + const max_read_bytes = 1024; + + while (true) { + if (options.default) |default| { + std.debug.print( + \\{s} [default: "{s}"]: + , .{ prompt, default }); + } else { + std.debug.print( + \\{s}: + , .{prompt}); + } + const input = try reader.readUntilDelimiterOrEofAlloc(allocator, '\n', max_read_bytes); + if (input) |capture| { + defer allocator.free(capture); + + if (std.mem.eql(u8, capture, "")) { + if (options.default) |default| return try allocator.dupe(u8, strip(default)); + } else return try allocator.dupe(u8, strip(capture)); + } + } +} + +// Strip leading and trailing whitespace from a u8 slice. +fn strip(input: []const u8) []const u8 { + return std.mem.trim(u8, input, &std.ascii.whitespace); +} + +// Initialize a new Git repository when setting up a new project (optional). +fn gitSetup(allocator: std.mem.Allocator, install_dir: *std.fs.Dir) !void { + try runCommand(allocator, install_dir, &[_][]const u8{ + "git", + "init", + ".", + }); + + try runCommand(allocator, install_dir, &[_][]const u8{ + "git", + "add", + ".", + }); + + try runCommand(allocator, install_dir, &[_][]const u8{ + "git", + "commit", + "-m", + "Initialize Jetzig project", + }); +} + +/// Print a success confirmation. +fn printSuccess() void { + std.debug.print(" ✅\n", .{}); +} + +/// Print a failure confirmation. +fn printFailure() void { + std.debug.print(" ❌\n", .{}); +} diff --git a/demo/build.zig.zon b/demo/build.zig.zon index 2e43c0f..321158c 100644 --- a/demo/build.zig.zon +++ b/demo/build.zig.zon @@ -1,5 +1,5 @@ .{ - .name = "sandbox-jetzig", + .name = "jetzig-demo", .version = "0.0.0", .minimum_zig_version = "0.12.0", .dependencies = .{ diff --git a/demo/public/styles.css b/demo/public/styles.css index e69de29..1755d47 100644 --- a/demo/public/styles.css +++ b/demo/public/styles.css @@ -0,0 +1,10 @@ +/* Root stylesheet. Load into your Zmpl template with: + * + * + * + */ + +.message { + font-weight: bold; + font-size: 3rem; +} diff --git a/demo/src/DemoMiddleware.zig b/demo/src/DemoMiddleware.zig deleted file mode 100644 index 2209795..0000000 --- a/demo/src/DemoMiddleware.zig +++ /dev/null @@ -1,26 +0,0 @@ -const std = @import("std"); -const jetzig = @import("jetzig"); - -my_data: u8, - -const Self = @This(); - -pub fn init(request: *jetzig.http.Request) !*Self { - var middleware = try request.allocator.create(Self); - middleware.my_data = 42; - return middleware; -} - -pub fn beforeRequest(self: *Self, request: *jetzig.http.Request) !void { - request.server.logger.debug("[DemoMiddleware] Before request, custom data: {d}", .{self.my_data}); - self.my_data = 43; -} - -pub fn afterRequest(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { - request.server.logger.debug("[DemoMiddleware] After request, custom data: {d}", .{self.my_data}); - request.server.logger.debug("[DemoMiddleware] content-type: {s}", .{response.content_type}); -} - -pub fn deinit(self: *Self, request: *jetzig.http.Request) void { - request.allocator.destroy(self); -} diff --git a/demo/src/app/middleware/DemoMiddleware.zig b/demo/src/app/middleware/DemoMiddleware.zig new file mode 100644 index 0000000..06822c7 --- /dev/null +++ b/demo/src/app/middleware/DemoMiddleware.zig @@ -0,0 +1,53 @@ +/// Demo middleware. Assign middleware by declaring `pub const middleware` in the +/// `jetzig_options` defined in your application's `src/main.zig`. +/// +/// Middleware is called before and after the request, providing full access to the active +/// request, allowing you to execute any custom code for logging, tracking, inserting response +/// headers, etc. +/// +/// This middleware is configured in the demo app's `src/main.zig`: +/// +/// ``` +/// pub const jetzig_options = struct { +/// pub const middleware: []const type = &.{@import("app/middleware/DemoMiddleware.zig")}; +/// }; +/// ``` +const std = @import("std"); +const jetzig = @import("jetzig"); + +/// Define any custom data fields you want to store here. Assigning to these fields in the `init` +/// function allows you to access them in the `beforeRequest` and `afterRequest` functions, where +/// they can also be modified. +my_custom_value: []const u8, + +const Self = @This(); + +/// Initialize middleware. +pub fn init(request: *jetzig.http.Request) !*Self { + var middleware = try request.allocator.create(Self); + middleware.my_custom_value = "initial value"; + return middleware; +} + +/// Invoked immediately after the request head has been processed, before relevant view function +/// is processed. This gives you access to request headers but not the request body. +pub fn beforeRequest(self: *Self, request: *jetzig.http.Request) !void { + request.server.logger.debug("[DemoMiddleware] my_custom_value: {s}", .{self.my_custom_value}); + self.my_custom_value = @tagName(request.method); +} + +/// Invoked immediately after the request has finished responding. Provides full access to the +/// response as well as the request. +pub fn afterRequest(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { + request.server.logger.debug( + "[DemoMiddleware] my_custom_value: {s}, response status: {s}", + .{ self.my_custom_value, @tagName(response.status_code) }, + ); +} + +/// Invoked after `afterRequest` is called, use this function to do any clean-up. +/// Note that `request.allocator` is an arena allocator, so any allocations are automatically +/// done before the next request starts processing. +pub fn deinit(self: *Self, request: *jetzig.http.Request) void { + request.allocator.destroy(self); +} diff --git a/demo/src/app/views/init.zig b/demo/src/app/views/init.zig new file mode 100644 index 0000000..0fa146c --- /dev/null +++ b/demo/src/app/views/init.zig @@ -0,0 +1,43 @@ +const jetzig = @import("jetzig"); + +/// `src/app/views/root.zig` represents the root URL `/` +/// The `index` view function is invoked when when the HTTP verb is `GET`. +/// Other view types are invoked either by passing a resource ID value (e.g. `/1234`) or by using +/// a different HTTP verb: +/// +/// GET / => index(request, data) +/// GET /1234 => get(id, request, data) +/// POST / => post(request, data) +/// PUT /1234 => put(id, request, data) +/// PATCH /1234 => patch(id, request, data) +/// DELETE /1234 => delete(id, request, data) +pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { + // The first call to `data.object()` or `data.array()` sets the root response data value. + // JSON requests return a JSON string representation of the root data value. + // Zmpl templates can access all values in the root data value. + var root = try data.object(); + + // Add a string to the root object. + try root.put("message", data.string("Welcome to Jetzig!")); + + // Request params have the same type as a `data.object()` so they can be inserted them + // directly into the response data. Fetch `http://localhost:8080/?message=hello` to set the + // param. JSON data is also accepted when the `content-type: application/json` header is + // present. + const params = try request.params(); + + if (params.get("message")) |value| { + try root.put("message_param", value); + } + + // Set arbitrary response headers as required. `content-type` is automatically assigned for + // HTML, JSON responses. + // + // Static files located in `public/` in the root of your project directory are accessible + // from the root path (e.g. `public/jetzig.png`) is available at `/jetzig.png` and the + // content type is inferred from the extension using MIME types. + try request.response.headers.append("x-example-header", "example header value"); + + // Render the response and set the response code. + return request.render(.ok); +} diff --git a/demo/src/app/views/init/_content.zmpl b/demo/src/app/views/init/_content.zmpl new file mode 100644 index 0000000..08973a1 --- /dev/null +++ b/demo/src/app/views/init/_content.zmpl @@ -0,0 +1,18 @@ +// Renders the `message` response data value. +