diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index 2b86562..f24529b 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -15,8 +15,6 @@ jobs:
build:
strategy:
matrix:
- #Deactivated windows for I don't know why it fails
- #os: [ubuntu-latest, macos-latest, windows-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
diff --git a/LICENSE-Zig b/LICENSE-Zig
new file mode 100644
index 0000000..9ce0137
--- /dev/null
+++ b/LICENSE-Zig
@@ -0,0 +1,21 @@
+The MIT License (Expat)
+
+Copyright (c) Zig contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/build.zig b/build.zig
index 9be5667..3f20f31 100644
--- a/build.zig
+++ b/build.zig
@@ -110,6 +110,7 @@ pub fn build(b: *std.Build) !void {
const test_build_options = b.addOptions();
test_build_options.addOption(Environment, "environment", .testing);
test_build_options.addOption(bool, "build_static", true);
+ test_build_options.addOption(bool, "debug_console", false);
const run_main_tests = b.addRunArtifact(main_tests);
main_tests.root_module.addOptions("build_options", test_build_options);
@@ -138,12 +139,24 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
"environment",
"Jetzig server environment.",
) orelse .development;
+
const build_static = b.option(
bool,
"build_static",
"Pre-render static routes. [default: false in development, true in testing/production]",
) orelse (environment != .development);
+ const debug_console = b.option(
+ bool,
+ "debug_console",
+ "Render a debug console on error. Default: false.",
+ ) orelse false;
+
+ if (debug_console == true and environment != .development) {
+ std.debug.print("Environment must be `development` when `debug_console` is enabled.", .{});
+ return error.JetzigBuildError;
+ }
+
const jetzig_dep = b.dependency(
"jetzig",
.{
@@ -171,6 +184,7 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
const build_options = b.addOptions();
build_options.addOption(Environment, "environment", environment);
build_options.addOption(bool, "build_static", build_static);
+ build_options.addOption(bool, "debug_console", debug_console);
jetzig_module.addOptions("build_options", build_options);
exe.root_module.addImport("jetzig", jetzig_module);
diff --git a/cli/commands/server.zig b/cli/commands/server.zig
index 87f6e22..e51c29f 100644
--- a/cli/commands/server.zig
+++ b/cli/commands/server.zig
@@ -9,22 +9,20 @@ pub const watch_changes_pause_duration = 1 * 1000 * 1000 * 1000;
/// Command line options for the `server` command.
pub const Options = struct {
reload: bool = true,
+ debug: bool = true,
pub const meta = .{
.full_text =
\\Launches a development server.
\\
- \\The development server reloads when files in `src/` are updated.
- \\
- \\To disable this behaviour, pass `--reload=false`
- \\
\\Example:
\\
\\ jetzig server
- \\ jetzig server --reload=false
+ \\ jetzig server --reload=false --debug=false
,
.option_docs = .{
.reload = "Enable or disable automatic reload on update (default: true)",
+ .debug = "Enable or disable the development debug console (default: true)",
},
};
};
@@ -62,18 +60,27 @@ pub fn run(
},
);
+ var argv = std.ArrayList([]const u8).init(allocator);
+ defer argv.deinit();
+
+ try argv.appendSlice(&.{
+ "zig",
+ "build",
+ util.environmentBuildOption(main_options.options.environment),
+ "-Djetzig_runner=true",
+ });
+
+ if (options.debug) try argv.append("-Ddebug_console=true");
+ try argv.appendSlice(&.{
+ "install",
+ "--color",
+ "on",
+ });
+
while (true) {
util.runCommandInDir(
allocator,
- &.{
- "zig",
- "build",
- util.environmentBuildOption(main_options.options.environment),
- "-Djetzig_runner=true",
- "install",
- "--color",
- "on",
- },
+ argv.items,
.{ .path = realpath },
) catch {
std.debug.print("Build failed, waiting for file change...\n", .{});
@@ -88,10 +95,9 @@ pub fn run(
std.process.exit(1);
}
- const argv = &[_][]const u8{exe_path.?};
defer allocator.free(exe_path.?);
- var process = std.process.Child.init(argv, allocator);
+ var process = std.process.Child.init(&.{exe_path.?}, allocator);
process.stdin_behavior = .Inherit;
process.stdout_behavior = .Inherit;
process.stderr_behavior = .Inherit;
diff --git a/demo/src/app/views/anti_csrf.zig b/demo/src/app/views/anti_csrf.zig
index e6f98ff..e30ecf4 100644
--- a/demo/src/app/views/anti_csrf.zig
+++ b/demo/src/app/views/anti_csrf.zig
@@ -1,7 +1,12 @@
const std = @import("std");
const jetzig = @import("jetzig");
-pub const layout = "application";
+// Anti-CSRF middleware can be included in the view's `actions` declaration to apply CSRF
+// protection just to this specific view, or it can be added to your application's global
+// middleware stack defined in `jetzig_options` in `src/main.zig`.
+//
+// Use `{{context.authenticityToken()}}` or `{{context.authenticityFormField()}}` in a Zmpl
+// template to generate a token, store it in the user's session, and inject it into the page.
pub const actions = .{
.before = .{jetzig.middleware.AntiCsrfMiddleware},
diff --git a/demo/src/app/views/anti_csrf/index.zmpl b/demo/src/app/views/anti_csrf/index.zmpl
index 4f89eaa..4b914a7 100644
--- a/demo/src/app/views/anti_csrf/index.zmpl
+++ b/demo/src/app/views/anti_csrf/index.zmpl
@@ -6,3 +6,5 @@
+
+
Try clearing `_jetzig_session` cookie before clicking "Submit Spam"
diff --git a/demo/src/app/views/render_template.zig b/demo/src/app/views/render_template.zig
new file mode 100644
index 0000000..bb352bf
--- /dev/null
+++ b/demo/src/app/views/render_template.zig
@@ -0,0 +1,15 @@
+const std = @import("std");
+const jetzig = @import("jetzig");
+
+pub fn index(request: *jetzig.Request) !jetzig.View {
+ return request.renderTemplate("basic/index", .ok);
+}
+
+test "index" {
+ var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
+ defer app.deinit();
+
+ const response = try app.request(.GET, "/render_template", .{});
+ try response.expectStatus(.ok);
+ try response.expectBodyContains("Hello");
+}
diff --git a/demo/src/app/views/render_template/index.zmpl b/demo/src/app/views/render_template/index.zmpl
new file mode 100644
index 0000000..76457d0
--- /dev/null
+++ b/demo/src/app/views/render_template/index.zmpl
@@ -0,0 +1,3 @@
+
+ Content goes here
+
diff --git a/demo/src/main.zig b/demo/src/main.zig
index 3360536..f051ee5 100644
--- a/demo/src/main.zig
+++ b/demo/src/main.zig
@@ -90,6 +90,7 @@ pub const jetzig_options = struct {
.same_site = true,
.secure = true,
.http_only = true,
+ .path = "/",
},
};
diff --git a/src/assets/debug.css b/src/assets/debug.css
new file mode 100644
index 0000000..8ed7f05
--- /dev/null
+++ b/src/assets/debug.css
@@ -0,0 +1,121 @@
+/* reset https://www.joshwcomeau.com/css/custom-css-reset/ */
+*, *::before, *::after {
+ box-sizing: border-box;
+}
+
+* {
+ margin: 0;
+}
+
+body {
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+
+img, picture, video, canvas, svg {
+ display: block;
+ max-width: 100%;
+}
+
+input, button, textarea, select {
+ font: inherit;
+}
+
+p, h1, h2, h3, h4, h5, h6 {
+ overflow-wrap: break-word;
+}
+
+p {
+ text-wrap: pretty;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ text-wrap: balance;
+}
+
+#root, #__next {
+ isolation: isolate;
+}
+
+/* styles */
+
+body {
+ background-color: #222;
+}
+
+h1 {
+ font-family: monospace;
+ color: #e55;
+ padding: 1rem;
+ font-size: 1.6rem;
+}
+
+h2 {
+ font-family: monospace;
+ color: #a0a0a0;
+ padding: 1rem;
+ font-size: 1.6rem;
+}
+
+.stack-trace {
+ /* background-color: #e555; */
+ padding: 1rem;
+ font-family: monospace;
+}
+
+.stack-trace .stack-source-line .file-name {
+ color: #90bfd7;
+ display: block;
+ padding: 0.4rem;
+ font-weight: bold;
+}
+
+.stack-trace .stack-source-line {
+ background-color: #333;
+ padding: 1rem;
+ margin: 0;
+ border-bottom: 1px solid #ffa3;
+}
+
+.stack-trace .stack-source-line:last-child {
+ border-bottom: none;
+}
+
+.stack-trace .stack-source-line .line-content {
+ margin: 0;
+ padding: 0;
+}
+
+.stack-trace .stack-source-line .line-content.surrounding {
+ color: #ffa;
+}
+
+.stack-trace .stack-source-line .line-content.target {
+ color: #faa;
+ background-color: #e552;
+}
+
+.stack-trace .stack-source-line .line-content .line-number {
+ display: inline;
+
+}
+
+pre {
+ display: inline;
+ margin-right: 1rem;
+}
+
+.response-data {
+ color: #fff;
+ background-color: #333;
+ padding: 1rem;
+ margin: 1rem;
+}
+
+/* PrismJS 1.29.0
+https://prismjs.com/download.html#themes=prism-tomorrow&languages=json+zig */
+code[class*=language-],pre[class*=language-]{color:#ccc;font-family:monospace;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]:not(pre)>code[class*=language-]{white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}
+
+.stack-trace .stack-source-line .line-number {
+ color: #ffa !important;
+}
diff --git a/src/assets/debug.js b/src/assets/debug.js
new file mode 100644
index 0000000..644900a
--- /dev/null
+++ b/src/assets/debug.js
@@ -0,0 +1,5 @@
+/* PrismJS 1.29.0
+https://prismjs.com/download.html#themes=prism-tomorrow&languages=json+zig */
+var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=g.reach);A+=w.value.length,w=w.next){var E=w.value;if(n.length>e.length)return;if(!(E instanceof i)){var P,L=1;if(y){if(!(P=l(b,A,e,m))||P.index>=e.length)break;var S=P.index,O=P.index+P[0].length,j=A;for(j+=w.value.length;S>=j;)j+=(w=w.next).value.length;if(A=j-=w.value.length,w.value instanceof i)continue;for(var C=w;C!==n.tail&&(jg.reach&&(g.reach=W);var z=w.prev;if(_&&(z=u(n,z,_),A+=_.length),c(n,z,L),w=u(n,z,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),L>1){var I={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,I),g&&I.reach>g.reach&&(g.reach=I.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a"+i.content+""+i.tag+">"},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism);
+Prism.languages.json={property:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?=\s*:)/,lookbehind:!0,greedy:!0},string:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?!\s*:)/,lookbehind:!0,greedy:!0},comment:{pattern:/\/\/.*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},number:/-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/i,punctuation:/[{}[\],]/,operator:/:/,boolean:/\b(?:false|true)\b/,null:{pattern:/\bnull\b/,alias:"keyword"}},Prism.languages.webmanifest=Prism.languages.json;
+!function(e){function n(e){return function(){return e}}var r=/\b(?:align|allowzero|and|anyframe|anytype|asm|async|await|break|cancel|catch|comptime|const|continue|defer|else|enum|errdefer|error|export|extern|fn|for|if|inline|linksection|nakedcc|noalias|nosuspend|null|or|orelse|packed|promise|pub|resume|return|stdcallcc|struct|suspend|switch|test|threadlocal|try|undefined|union|unreachable|usingnamespace|var|volatile|while)\b/,a="\\b(?!"+r.source+")(?!\\d)\\w+\\b",o="align\\s*\\((?:[^()]|\\([^()]*\\))*\\)",s="(?!\\s)(?:!?\\s*(?:"+"(?:\\?|\\bpromise->|(?:\\[[^[\\]]*\\]|\\*(?!\\*)|\\*\\*)(?:\\s*|\\s*const\\b|\\s*volatile\\b|\\s*allowzero\\b)*)".replace(//g,n(o))+"\\s*)*"+"(?:\\bpromise\\b|(?:\\berror\\.)?(?:\\.)*(?!\\s+))".replace(//g,n(a))+")+";e.languages.zig={comment:[{pattern:/\/\/[/!].*/,alias:"doc-comment"},/\/{2}.*/],string:[{pattern:/(^|[^\\@])c?"(?:[^"\\\r\n]|\\.)*"/,lookbehind:!0,greedy:!0},{pattern:/([\r\n])([ \t]+c?\\{2}).*(?:(?:\r\n?|\n)\2.*)*/,lookbehind:!0,greedy:!0}],char:{pattern:/(^|[^\\])'(?:[^'\\\r\n]|[\uD800-\uDFFF]{2}|\\(?:.|x[a-fA-F\d]{2}|u\{[a-fA-F\d]{1,6}\}))'/,lookbehind:!0,greedy:!0},builtin:/\B@(?!\d)\w+(?=\s*\()/,label:{pattern:/(\b(?:break|continue)\s*:\s*)\w+\b|\b(?!\d)\w+\b(?=\s*:\s*(?:\{|while\b))/,lookbehind:!0},"class-name":[/\b(?!\d)\w+(?=\s*=\s*(?:(?:extern|packed)\s+)?(?:enum|struct|union)\s*[({])/,{pattern:RegExp("(:\\s*)(?=\\s*(?:\\s*)?[=;,)])|(?=\\s*(?:\\s*)?\\{)".replace(//g,n(s)).replace(//g,n(o))),lookbehind:!0,inside:null},{pattern:RegExp("(\\)\\s*)(?=\\s*(?:\\s*)?;)".replace(//g,n(s)).replace(//g,n(o))),lookbehind:!0,inside:null}],"builtin-type":{pattern:/\b(?:anyerror|bool|c_u?(?:int|long|longlong|short)|c_longdouble|c_void|comptime_(?:float|int)|f(?:16|32|64|128)|[iu](?:8|16|32|64|128|size)|noreturn|type|void)\b/,alias:"keyword"},keyword:r,function:/\b(?!\d)\w+(?=\s*\()/,number:/\b(?:0b[01]+|0o[0-7]+|0x[a-fA-F\d]+(?:\.[a-fA-F\d]*)?(?:[pP][+-]?[a-fA-F\d]+)?|\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)\b/,boolean:/\b(?:false|true)\b/,operator:/\.[*?]|\.{2,3}|[-=]>|\*\*|\+\+|\|\||(?:<<|>>|[-+*]%|[-+*/%^&|<>!=])=?|[?~]/,punctuation:/[.:,;(){}[\]]/},e.languages.zig["class-name"].forEach((function(n){null===n.inside&&(n.inside=e.languages.zig)}))}(Prism);
diff --git a/src/jetzig.zig b/src/jetzig.zig
index de8af9e..db5a2c1 100644
--- a/src/jetzig.zig
+++ b/src/jetzig.zig
@@ -23,6 +23,7 @@ pub const testing = @import("jetzig/testing.zig");
pub const config = @import("jetzig/config.zig");
pub const auth = @import("jetzig/auth.zig");
pub const callbacks = @import("jetzig/callbacks.zig");
+pub const debug = @import("jetzig/debug.zig");
pub const TemplateContext = @import("jetzig/TemplateContext.zig");
pub const DateTime = jetcommon.types.DateTime;
diff --git a/src/jetzig/debug.zig b/src/jetzig/debug.zig
new file mode 100644
index 0000000..80d634e
--- /dev/null
+++ b/src/jetzig/debug.zig
@@ -0,0 +1,218 @@
+const std = @import("std");
+const builtin = @import("builtin");
+
+pub const ErrorInfo = struct {
+ stack_trace: ?*std.builtin.StackTrace = null,
+ err: ?anyerror = null,
+};
+
+pub fn sourceLocations(
+ allocator: std.mem.Allocator,
+ debug_info: *std.debug.SelfInfo,
+ stack_trace: *std.builtin.StackTrace,
+) ![]const std.debug.SourceLocation {
+ var source_locations = std.ArrayList(std.debug.SourceLocation).init(allocator);
+
+ if (builtin.strip_debug_info) return error.MissingDebugInfo;
+
+ var frame_index: usize = 0;
+ var frames_left: usize = @min(stack_trace.index, stack_trace.instruction_addresses.len);
+
+ while (frames_left != 0) : ({
+ frames_left -= 1;
+ frame_index = (frame_index + 1) % stack_trace.instruction_addresses.len;
+ }) {
+ const return_address = stack_trace.instruction_addresses[frame_index];
+ const address = return_address - 1;
+ const module = try debug_info.getModuleForAddress(address);
+ const symbol_info = try module.getSymbolAtAddress(debug_info.allocator, address);
+
+ if (symbol_info.source_location) |source_location| {
+ try source_locations.append(source_location);
+ }
+ }
+
+ return try source_locations.toOwnedSlice();
+}
+
+pub const HtmlStackTrace = struct {
+ allocator: std.mem.Allocator,
+ stack_trace: *std.builtin.StackTrace,
+
+ pub fn format(self: HtmlStackTrace, _: anytype, _: anytype, writer: anytype) !void {
+ const debug_info = try std.debug.getSelfDebugInfo();
+ const source_locations = try sourceLocations(
+ self.allocator,
+ debug_info,
+ self.stack_trace,
+ );
+ for (source_locations) |source_location| {
+ defer debug_info.allocator.free(source_location.file_name);
+ try writer.print(
+ \\
+ \\ {s}:{d}
+ \\
+ ,
+ .{
+ source_location.file_name,
+ source_location.line,
+ },
+ );
+ const surrounding_previous = try surroundingLinesFromFile(
+ self.allocator,
+ .previous,
+ 3,
+ source_location.file_name,
+ source_location.line,
+ );
+ const surrounding_next = try surroundingLinesFromFile(
+ self.allocator,
+ .next,
+ 3,
+ source_location.file_name,
+ source_location.line,
+ );
+ const target_source_line = try readLineFromFile(
+ self.allocator,
+ source_location.file_name,
+ source_location.line,
+ );
+
+ for (surrounding_previous) |source_line| {
+ try writer.print(surrounding_line_template, .{ source_line.line, source_line.content });
+ }
+ try writer.print(target_line_template, .{ target_source_line.line, target_source_line.content });
+ for (surrounding_next) |source_line| {
+ try writer.print(surrounding_line_template, .{ source_line.line, source_line.content });
+ }
+
+ try writer.print(
+ \\
+ \\
+ , .{});
+ }
+ }
+};
+
+const SourceLine = struct { content: []const u8, line: usize };
+
+pub fn readLineFromFile(allocator: std.mem.Allocator, path: []const u8, line: usize) !SourceLine {
+ const file = try std.fs.openFileAbsolute(path, .{});
+ var buf: [std.mem.page_size]u8 = undefined;
+
+ var count: usize = 1;
+ var cursor: usize = 0;
+
+ seek: {
+ while (true) {
+ const bytes_read = try file.readAll(buf[0..]);
+ for (buf[0..bytes_read]) |char| {
+ if (char == '\n') count += 1;
+ cursor += 1;
+ if (count == line) {
+ cursor += 1;
+ break :seek;
+ }
+ }
+ if (bytes_read < buf.len) return error.EndOfFile;
+ }
+ }
+
+ var size: usize = 0;
+ try file.seekTo(cursor);
+ read: {
+ const bytes_read = try file.readAll(buf[0..]);
+ if (std.mem.indexOf(u8, buf[0..bytes_read], "\n")) |index| {
+ size += index;
+ break :read;
+ } else if (bytes_read < buf.len) {
+ size += bytes_read;
+ break :read;
+ } else {
+ while (true) {
+ const more_bytes_read = try file.readAll(buf[0..]);
+ if (std.mem.indexOf(u8, buf[0..more_bytes_read], "\n")) |index| {
+ size += index;
+ break :read;
+ } else if (more_bytes_read < buf.len) {
+ size += more_bytes_read;
+ break :read;
+ } else {
+ size += more_bytes_read;
+ }
+ }
+ }
+ }
+ const line_content = try allocator.alloc(u8, size);
+ try file.seekTo(cursor);
+ const bytes_read = try file.readAll(line_content[0..]);
+
+ std.debug.assert(bytes_read == size);
+
+ return .{ .content = line_content[0..], .line = line };
+}
+
+fn surroundingLinesFromFile(
+ allocator: std.mem.Allocator,
+ context: enum { previous, next },
+ desired_count: usize,
+ path: []const u8,
+ target_line: usize,
+) ![]SourceLine {
+ // This isn't very efficient but we only use it in debug mode so not a huge deal.
+ const start = switch (context) {
+ .previous => if (target_line > desired_count)
+ target_line - desired_count
+ else
+ target_line,
+ .next => target_line + 2,
+ };
+
+ var lines = std.ArrayList(SourceLine).init(allocator);
+
+ switch (context) {
+ .previous => {
+ for (start..target_line) |line| {
+ try lines.append(try readLineFromFile(allocator, path, line));
+ }
+ },
+ .next => {
+ for (0..desired_count, start..) |_, line| {
+ try lines.append(try readLineFromFile(allocator, path, line));
+ }
+ },
+ }
+
+ return try lines.toOwnedSlice();
+}
+
+pub const console_template =
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\ Encountered Error: {0s}
+ \\
+ \\ {1}
+ \\
+ \\ Response Data
+ \\
+ \\
+ \\
+ \\
+;
+
+const surrounding_line_template =
+ \\
+ \\
+;
+const target_line_template =
+ \\
+ \\
+;
diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig
index b864913..41de5c9 100644
--- a/src/jetzig/http/Request.zig
+++ b/src/jetzig/http/Request.zig
@@ -11,6 +11,16 @@ pub const Method = enum { DELETE, GET, PATCH, POST, HEAD, PUT, CONNECT, OPTIONS,
pub const Modifier = enum { edit, new };
pub const Format = enum { HTML, JSON, UNKNOWN };
pub const Protocol = enum { http, https };
+pub const RequestState = enum {
+ initial, // No processing has taken place
+ processed, // Request headers have been processed
+ after_request, // Initial middleware processing
+ rendered, // Rendered by middleware or view
+ redirected, // Redirected by middleware or view
+ failed, // Failed by middleware or view
+ before_response, // Post middleware processing
+ finalized, // Response generated
+};
allocator: std.mem.Allocator,
path: jetzig.http.Path,
@@ -30,19 +40,12 @@ parsed_multipart: ?*jetzig.data.Data = null,
_cookies: ?*jetzig.http.Cookies = null,
_session: ?*jetzig.http.Session = null,
body: []const u8 = undefined,
-state: enum { initial, processed } = .initial,
-response_started: bool = false,
+state: RequestState = .initial,
dynamic_assigned_template: ?[]const u8 = null,
layout: ?[]const u8 = null,
layout_disabled: bool = false,
-// TODO: Squash rendered/redirected/failed into
-// `state: enum { initial, rendered, redirected, failed }`
-rendered: bool = false,
-redirected: bool = false,
-failed: bool = false,
redirect_state: ?RedirectState = null,
middleware_rendered: ?struct { name: []const u8, action: []const u8 } = null,
-middleware_rendered_during_response: bool = false,
middleware_data: jetzig.http.middleware.MiddlewareData = undefined,
rendered_multiple: bool = false,
rendered_view: ?jetzig.views.View = null,
@@ -177,6 +180,7 @@ pub fn respond(self: *Request) !void {
const status = jetzig.http.status_codes.get(self.response.status_code);
self.httpz_response.status = try status.getCodeInt();
self.httpz_response.body = self.response.content;
+ self.state = .finalized;
}
/// Set the root value for response data.
@@ -191,10 +195,9 @@ pub fn data(self: Request, comptime root: @TypeOf(.enum_literal)) !*jetzig.Data.
/// Render a response. This function can only be called once per request (repeat calls will
/// trigger an error).
pub fn render(self: *Request, status_code: jetzig.http.status_codes.StatusCode) jetzig.views.View {
- if (self.rendered or self.failed) self.rendered_multiple = true;
+ if (self.isRendered()) self.rendered_multiple = true;
- self.rendered = true;
- if (self.response_started) self.middleware_rendered_during_response = true;
+ self.state = .rendered;
self.rendered_view = .{ .data = self.response_data, .status_code = status_code };
return self.rendered_view.?;
}
@@ -202,15 +205,20 @@ pub fn render(self: *Request, status_code: jetzig.http.status_codes.StatusCode)
/// Render an error. This function can only be called once per request (repeat calls will
/// trigger an error).
pub fn fail(self: *Request, status_code: jetzig.http.status_codes.StatusCode) jetzig.views.View {
- if (self.rendered or self.redirected) self.rendered_multiple = true;
+ if (self.isRendered()) self.rendered_multiple = true;
- self.rendered = true;
- self.failed = true;
- if (self.response_started) self.middleware_rendered_during_response = true;
+ self.state = .failed;
self.rendered_view = .{ .data = self.response_data, .status_code = status_code };
return self.rendered_view.?;
}
+pub inline fn isRendered(self: *const Request) bool {
+ return switch (self.state) {
+ .initial, .processed, .after_request, .before_response => false,
+ .rendered, .redirected, .failed, .finalized => true,
+ };
+}
+
/// Issue a redirect to a new location.
/// ```zig
/// return request.redirect("https://www.example.com/", .moved_permanently);
@@ -224,11 +232,9 @@ pub fn redirect(
location: []const u8,
redirect_status: enum { moved_permanently, found },
) jetzig.views.View {
- if (self.rendered or self.failed) self.rendered_multiple = true;
+ if (self.isRendered()) self.rendered_multiple = true;
- self.rendered = true;
- self.redirected = true;
- if (self.response_started) self.middleware_rendered_during_response = true;
+ self.state = .redirected;
const status_code = switch (redirect_status) {
.moved_permanently => jetzig.http.status_codes.StatusCode.moved_permanently,
@@ -687,6 +693,19 @@ pub fn setTemplate(self: *Request, name: []const u8) void {
self.dynamic_assigned_template = name;
}
+/// Set a custom template and render the response.
+/// ```zig
+/// return request.renderTemplate("blogs/comments/get", .ok);
+/// ```
+pub fn renderTemplate(
+ self: *Request,
+ name: []const u8,
+ status_code: jetzig.http.StatusCode,
+) jetzig.views.View {
+ self.dynamic_assigned_template = name;
+ return self.render(status_code);
+}
+
pub fn joinPath(self: *const Request, args: anytype) ![]const u8 {
const fields = std.meta.fields(@TypeOf(args));
var buf: [fields.len][]const u8 = undefined;
diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig
index 819067a..ca0c5d4 100644
--- a/src/jetzig/http/Server.zig
+++ b/src/jetzig/http/Server.zig
@@ -138,7 +138,8 @@ pub fn processNextRequest(
var middleware_data = try jetzig.http.middleware.afterRequest(&request);
- if (request.middleware_rendered) |_| { // Request processing ends when a middleware renders or redirects.
+ if (request.middleware_rendered) |_| {
+ // Request processing ends when a middleware renders or redirects.
if (request.redirect_state) |state| {
try request.renderRedirect(state);
} else if (request.rendered_view) |rendered| {
@@ -150,8 +151,8 @@ pub fn processNextRequest(
} else {
try self.renderResponse(&request);
try request.response.headers.append("Content-Type", response.content_type);
- try jetzig.http.middleware.beforeResponse(&middleware_data, &request);
+ try jetzig.http.middleware.beforeResponse(&middleware_data, &request);
try request.respond();
try jetzig.http.middleware.afterResponse(&middleware_data, &request);
jetzig.http.middleware.deinit(&middleware_data, &request);
@@ -197,9 +198,12 @@ fn renderResponse(self: *Server, request: *jetzig.http.Request) !void {
for (route.before_callbacks) |callback| {
try callback(request, route);
if (request.rendered_view) |view| {
- if (request.failed) {
- request.setResponse(try self.renderError(request, view.status_code), .{});
- } else if (request.rendered) {
+ if (request.state == .failed) {
+ request.setResponse(
+ try self.renderError(request, view.status_code, .{}),
+ .{},
+ );
+ } else if (request.state == .rendered) {
// TODO: Allow callbacks to set content
}
return;
@@ -259,7 +263,9 @@ fn renderHTML(
return request.setResponse(rendered_error, .{});
};
- return if (request.redirected or request.failed or request.dynamic_assigned_template != null)
+ return if (request.state == .redirected or
+ request.state == .failed or
+ request.dynamic_assigned_template != null)
request.setResponse(rendered, .{})
else
request.setResponse(try self.renderNotFound(request), .{});
@@ -327,12 +333,12 @@ fn renderView(
return try self.renderInternalServerError(request, @errorReturnTrace(), err);
};
- if (request.failed) {
+ if (request.state == .failed) {
const view: jetzig.views.View = request.rendered_view orelse .{
.data = request.response_data,
.status_code = .internal_server_error,
};
- return try self.renderError(request, view.status_code);
+ return try self.renderError(request, view.status_code, .{});
}
const template: ?zmpl.Template = if (request.dynamic_assigned_template) |request_template|
@@ -343,7 +349,7 @@ fn renderView(
if (request.rendered_multiple) return error.JetzigMultipleRenderError;
if (request.rendered_view) |rendered_view| {
- if (request.redirected) return .{ .view = rendered_view, .content = "" };
+ if (request.state == .redirected) return .{ .view = rendered_view, .content = "" };
if (template) |capture| {
return .{
@@ -352,12 +358,21 @@ fn renderView(
};
} else {
return switch (request.requestFormat()) {
- .HTML, .UNKNOWN => try self.renderNotFound(request),
+ .HTML, .UNKNOWN => blk: {
+ try self.logger.DEBUG(
+ "Missing template for route `{s}.{s}`. Expected: `src/app/views/{s}.zmpl`.",
+ .{ route.view_name, @tagName(route.action), route.template },
+ );
+ if (comptime jetzig.build_options.debug_console) {
+ return error.ZmplTemplateNotFound;
+ }
+ break :blk try self.renderNotFound(request);
+ },
.JSON => .{ .view = rendered_view, .content = "" },
};
}
} else {
- if (!request.redirected) {
+ if (request.state == .processed) {
try self.logger.WARN("`request.render` was not invoked. Rendering empty content.", .{});
}
request.response_data.reset();
@@ -461,32 +476,42 @@ fn renderInternalServerError(
stack_trace: ?*std.builtin.StackTrace,
err: anyerror,
) !RenderedView {
- request.response_data.reset();
-
try self.logger.logError(stack_trace, err);
- const status = .internal_server_error;
- return try self.renderError(request, status);
+ const status = jetzig.http.StatusCode.internal_server_error;
+
+ return try self.renderError(request, status, .{ .stack_trace = stack_trace, .err = err });
}
fn renderNotFound(self: *Server, request: *jetzig.http.Request) !RenderedView {
request.response_data.reset();
const status: jetzig.http.StatusCode = .not_found;
- return try self.renderError(request, status);
+ return try self.renderError(request, status, .{});
}
fn renderBadRequest(self: *Server, request: *jetzig.http.Request) !RenderedView {
request.response_data.reset();
const status: jetzig.http.StatusCode = .bad_request;
- return try self.renderError(request, status);
+ return try self.renderError(request, status, .{});
}
fn renderError(
self: Server,
request: *jetzig.http.Request,
status_code: jetzig.http.StatusCode,
+ error_info: jetzig.debug.ErrorInfo,
+) !RenderedView {
+ if (comptime jetzig.build_options.debug_console) {
+ return try self.renderDebugConsole(request, status_code, error_info);
+ } else return try self.renderGeneralError(request, status_code);
+}
+
+fn renderGeneralError(
+ self: Server,
+ request: *jetzig.http.Request,
+ status_code: jetzig.http.StatusCode,
) !RenderedView {
if (try self.renderErrorView(request, status_code)) |view| return view;
if (try renderStaticErrorPage(request, status_code)) |view| return view;
@@ -494,6 +519,42 @@ fn renderError(
return try renderDefaultError(request, status_code);
}
+fn renderDebugConsole(
+ self: Server,
+ request: *jetzig.http.Request,
+ status_code: jetzig.http.StatusCode,
+ error_info: jetzig.debug.ErrorInfo,
+) !RenderedView {
+ if (comptime jetzig.build_options.debug_console) {
+ var buf = std.ArrayList(u8).init(request.allocator);
+ const writer = buf.writer();
+
+ if (error_info.stack_trace) |stack_trace| {
+ const debug_content = jetzig.debug.HtmlStackTrace{
+ .allocator = request.allocator,
+ .stack_trace = stack_trace,
+ };
+ const error_name = if (error_info.err) |err| @errorName(err) else "[UnknownError]";
+ try writer.print(
+ jetzig.debug.console_template,
+ .{
+ error_name,
+ debug_content,
+ try request.response_data.toJsonOptions(.{ .pretty = true }),
+ @embedFile("../../assets/debug.css"),
+ },
+ );
+ } else return try self.renderGeneralError(request, status_code);
+
+ const content = try buf.toOwnedSlice();
+
+ return .{
+ .view = .{ .data = request.response_data, .status_code = status_code },
+ .content = if (content.len == 0) "" else content,
+ };
+ } else unreachable;
+}
+
fn renderErrorView(
self: Server,
request: *jetzig.http.Request,
@@ -641,6 +702,13 @@ const StaticResource = struct {
};
fn matchStaticResource(self: *Server, request: *jetzig.http.Request) !?StaticResource {
+ if (comptime jetzig.build_options.debug_console) {
+ if (std.mem.eql(u8, request.path.path, "/_jetzig_debug.js")) return .{
+ .content = @embedFile("../../assets/debug.js"),
+ .mime_type = "text/javascript",
+ };
+ }
+
// TODO: Map public and static routes at launch to avoid accessing the file system when
// matching any route - currently every request causes file system traversal.
const public_resource = try self.matchPublicContent(request);
diff --git a/src/jetzig/http/middleware.zig b/src/jetzig/http/middleware.zig
index a6feace..e049a17 100644
--- a/src/jetzig/http/middleware.zig
+++ b/src/jetzig/http/middleware.zig
@@ -60,8 +60,11 @@ pub fn afterRequest(request: *jetzig.http.Request) !MiddlewareData {
}
}
+ request.state = .after_request;
+
inline for (middlewares, 0..) |middleware, index| {
if (comptime !@hasDecl(middleware, "afterRequest")) continue;
+
if (comptime @hasDecl(middleware, "init")) {
const data = middleware_data.get(index).?;
try @call(
@@ -72,7 +75,8 @@ pub fn afterRequest(request: *jetzig.http.Request) !MiddlewareData {
} else {
try @call(.always_inline, middleware.afterRequest, .{request});
}
- if (request.rendered or request.redirected) {
+
+ if (request.state != .after_request) {
request.middleware_rendered = .{ .name = @typeName(middleware), .action = "afterRequest" };
break;
}
@@ -86,11 +90,11 @@ pub fn beforeResponse(
middleware_data: *MiddlewareData,
request: *jetzig.http.Request,
) !void {
- request.response_started = true;
+ request.state = .before_response;
inline for (middlewares, 0..) |middleware, index| {
if (comptime !@hasDecl(middleware, "beforeResponse")) continue;
- if (!request.middleware_rendered_during_response) {
+ if (request.state == .before_response) {
if (comptime @hasDecl(middleware, "init")) {
const data = middleware_data.get(index).?;
try @call(
@@ -99,11 +103,19 @@ pub fn beforeResponse(
.{ @as(*middleware, @ptrCast(@alignCast(data))), request, request.response },
);
} else {
- try @call(.always_inline, middleware.beforeResponse, .{ request, request.response });
+ try @call(
+ .always_inline,
+ middleware.beforeResponse,
+ .{ request, request.response },
+ );
}
}
- if (request.middleware_rendered_during_response) {
- request.middleware_rendered = .{ .name = @typeName(middleware), .action = "beforeResponse" };
+
+ if (request.state != .before_response) {
+ request.middleware_rendered = .{
+ .name = @typeName(middleware),
+ .action = "beforeResponse",
+ };
break;
}
}
diff --git a/src/jetzig/loggers/DevelopmentLogger.zig b/src/jetzig/loggers/DevelopmentLogger.zig
index 3ae6ba9..8ff0dbf 100644
--- a/src/jetzig/loggers/DevelopmentLogger.zig
+++ b/src/jetzig/loggers/DevelopmentLogger.zig
@@ -107,7 +107,7 @@ pub fn logRequest(self: DevelopmentLogger, request: *const jetzig.http.Request)
if (request.middleware_rendered) |middleware| middleware.action else "",
if (request.middleware_rendered) |_| jetzig.colors.codes.escape ++ jetzig.colors.codes.white ++ ":" else "",
if (request.middleware_rendered) |_| jetzig.colors.codes.escape ++ jetzig.colors.codes.bright_cyan else "",
- if (request.middleware_rendered) |_| if (request.redirected) "redirect" else "render" else "",
+ if (request.middleware_rendered) |_| @tagName(request.state) else "",
if (request.middleware_rendered) |_| jetzig.colors.codes.escape ++ jetzig.colors.codes.reset else "",
if (request.middleware_rendered) |_| "]" else "",
request.path.path,
diff --git a/src/jetzig/loggers/ProductionLogger.zig b/src/jetzig/loggers/ProductionLogger.zig
index 6c7c51c..65c7ffb 100644
--- a/src/jetzig/loggers/ProductionLogger.zig
+++ b/src/jetzig/loggers/ProductionLogger.zig
@@ -86,7 +86,7 @@ pub fn logRequest(self: ProductionLogger, request: *const jetzig.http.Request) !
if (request.middleware_rendered) |_| ":" else "",
if (request.middleware_rendered) |middleware| middleware.action else "",
if (request.middleware_rendered) |_| ":" else "",
- if (request.middleware_rendered) |_| if (request.redirected) "redirect" else "render" else "",
+ if (request.middleware_rendered) |_| @tagName(request.state) else "",
if (request.middleware_rendered) |_| "]" else "",
request.path.path,
}, .stdout);
diff --git a/src/jetzig/middleware/HtmxMiddleware.zig b/src/jetzig/middleware/HtmxMiddleware.zig
index 022d010..a727f0e 100644
--- a/src/jetzig/middleware/HtmxMiddleware.zig
+++ b/src/jetzig/middleware/HtmxMiddleware.zig
@@ -20,7 +20,11 @@ pub fn afterRequest(request: *jetzig.http.Request) !void {
/// If a redirect was issued during request processing, reset any response data, set response
/// status to `200 OK` and replace the `Location` header with a `HX-Redirect` header.
pub fn beforeResponse(request: *jetzig.http.Request, response: *jetzig.http.Response) !void {
- if (response.status_code != .moved_permanently and response.status_code != .found) return;
+ switch (response.status_code) {
+ .moved_permanently, .found => {},
+ else => return,
+ }
+
if (request.headers.get("HX-Request") == null) return;
if (response.headers.get("Location")) |location| {