diff --git a/build.zig b/build.zig index c0e81e8..0164e8e 100644 --- a/build.zig +++ b/build.zig @@ -9,11 +9,22 @@ const Environment = enum { development, testing, production }; pub fn build(b: *std.Build) !void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + var jetzig_templates_path = std.ArrayList([]const u8).init(b.allocator); + try jetzig_templates_path.append("/"); + var it = std.mem.splitSequence( + u8, + try b.path("src/jetzig/templates").getPath3(b, null).toString(b.allocator), + std.fs.path.sep_str, + ); + while (it.next()) |segment| { + try jetzig_templates_path.append(segment); + } const templates_paths = try zmpl_build.templatesPaths( b.allocator, &.{ .{ .prefix = "views", .path = &.{ "src", "app", "views" } }, .{ .prefix = "mailers", .path = &.{ "src", "app", "mailers" } }, + .{ .prefix = "jetzig", .path = jetzig_templates_path.items }, }, ); diff --git a/build.zig.zon b/build.zig.zon index bcbe33f..c096f5f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,8 +7,9 @@ .hash = "1220d0e8734628fd910a73146e804d10a3269e3e7d065de6bb0e3e88d5ba234eb163", }, .zmpl = .{ - .url = "https://github.com/jetzig-framework/zmpl/archive/f9a3c602d060d6b337312b820c852376cd111766.tar.gz", - .hash = "1220f61c70456b8bb7f407ff539d1d8569acea64f68db12f9245f1d4113495c33907", + // .url = "https://github.com/jetzig-framework/zmpl/archive/7b5e0309ee49c06b99c242fecd218d3f3d15cd40.tar.gz", + // .hash = "12204d61eb58ee860f748e5817ef9300ad56c9d5efef84864ae590c87baf2e0380a1", + .path = "../zmpl", }, .jetkv = .{ .url = "https://github.com/jetzig-framework/jetkv/archive/acaa30db281f1c331d20c48cfe6539186549ad45.tar.gz", diff --git a/demo/src/main.zig b/demo/src/main.zig index 26f638a..3cccb0e 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -15,6 +15,7 @@ pub const jetzig_options = struct { // jetzig.middleware.AuthMiddleware, // jetzig.middleware.AntiCsrfMiddleware, // jetzig.middleware.HtmxMiddleware, + jetzig.middleware.InertiaMiddleware, // jetzig.middleware.CompressionMiddleware, // @import("app/middleware/DemoMiddleware.zig"), }; diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 0b47ed6..001dfd6 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -15,6 +15,7 @@ pub const RequestState = enum { initial, // No processing has taken place processed, // Request headers have been processed after_request, // Initial middleware processing + after_view, // View returned, response data ready for full response render rendered, // Rendered by middleware or view redirected, // Redirected by middleware or view failed, // Failed by middleware or view @@ -216,6 +217,20 @@ pub fn render(self: *Request, status_code: jetzig.http.status_codes.StatusCode) return self.rendered_view.?; } +/// Render a response with pre-rendered content. This function can only be called once per +/// request (repeat calls will trigger an error). +pub fn renderContent( + self: *Request, + status_code: jetzig.http.status_codes.StatusCode, + content: []const u8, +) jetzig.views.View { + if (self.isRendered()) self.rendered_multiple = true; + + self.state = .rendered; + self.rendered_view = .{ .data = self.response_data, .status_code = status_code, .content = content }; + return self.rendered_view.?; +} + /// 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 { @@ -229,7 +244,7 @@ pub fn fail(self: *Request, status_code: jetzig.http.status_codes.StatusCode) je pub inline fn isRendered(self: *const Request) bool { return switch (self.state) { .initial, .processed, .after_request, .before_response => false, - .rendered, .redirected, .failed, .finalized => true, + .after_view, .rendered, .redirected, .failed, .finalized => true, }; } diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index 4ba090c..2b0e1f1 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -150,6 +150,11 @@ pub fn processNextRequest( try request.process(); + // Allow middleware to render templates even though we have not mapped a view/action yet. + // TODO: We should probably separate the routing and map routes before we invoke middleware. + try request.response_data.addConst("jetzig_action", request.response_data.string("")); + try request.response_data.addConst("jetzig_view", request.response_data.string("")); + var middleware_data = try jetzig.http.middleware.afterRequest(&request); if (request.middleware_rendered) |_| { @@ -157,13 +162,12 @@ pub fn processNextRequest( if (request.redirect_state) |state| { try request.renderRedirect(state); } else if (request.rendered_view) |rendered| { - // TODO: Allow middleware to set content - request.setResponse(.{ .view = rendered, .content = "" }, .{}); + request.setResponse(.{ .view = rendered, .content = rendered.content orelse "" }, .{}); } try request.response.headers.append("Content-Type", response.content_type); try request.respond(); } else { - try self.renderResponse(&request); + try self.renderResponse(&request, &middleware_data); try request.response.headers.append("Content-Type", response.content_type); try jetzig.http.middleware.beforeResponse(&middleware_data, &request); @@ -175,7 +179,11 @@ pub fn processNextRequest( try self.logger.logRequest(&request); } -fn renderResponse(self: *Server, request: *jetzig.http.Request) !void { +fn renderResponse( + self: *Server, + request: *jetzig.http.Request, + middleware_data: *jetzig.http.middleware.MiddlewareData, +) !void { const static_resource = self.matchStaticResource(request) catch |err| { if (isUnhandledError(err)) return err; @@ -227,6 +235,34 @@ fn renderResponse(self: *Server, request: *jetzig.http.Request) !void { return; } } + + // View functions return a `View` to encourage users to return from a view function with + // `return request.render(.ok)`, but the actual rendered view is stored in + // `request.rendered_view`. + _ = route.render(route, request) catch |err| { + if (isUnhandledError(err)) return err; + const rendered_error = if (isBadRequest(err)) + try self.renderBadRequest(request) + else + try self.renderInternalServerError(request, @errorReturnTrace(), err); + request.setResponse(rendered_error, .{}); + return; + }; + } + + if (request.rendered_view != null) { + try jetzig.http.middleware.afterView(middleware_data, request); + } + + 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| { + request.setResponse(.{ .view = rendered, .content = rendered.content orelse "" }, .{}); + } + try request.response.headers.append("Content-Type", request.response.content_type); + return try request.respond(); } switch (request.requestFormat()) { @@ -338,15 +374,6 @@ fn renderView( request: *jetzig.http.Request, maybe_template: ?zmpl.Template, ) !RenderedView { - // View functions return a `View` to encourage users to return from a view function with - // `return request.render(.ok)`, but the actual rendered view is stored in - // `request.rendered_view`. - _ = route.render(route, request) catch |err| { - if (isUnhandledError(err)) return err; - if (isBadRequest(err)) return try self.renderBadRequest(request); - return try self.renderInternalServerError(request, @errorReturnTrace(), err); - }; - if (request.state == .failed) { const view: jetzig.views.View = request.rendered_view orelse .{ .data = request.response_data, diff --git a/src/jetzig/http/middleware.zig b/src/jetzig/http/middleware.zig index e049a17..25a3c27 100644 --- a/src/jetzig/http/middleware.zig +++ b/src/jetzig/http/middleware.zig @@ -86,6 +86,38 @@ pub fn afterRequest(request: *jetzig.http.Request) !MiddlewareData { return middleware_data; } +pub fn afterView(middleware_data: *MiddlewareData, request: *jetzig.http.Request) !void { + request.state = .after_view; + + inline for (middlewares, 0..) |middleware, index| { + if (comptime !@hasDecl(middleware, "afterView")) continue; + if (request.state == .after_view) { + if (comptime @hasDecl(middleware, "init")) { + const data = middleware_data.get(index).?; + try @call( + .always_inline, + middleware.afterView, + .{ @as(*middleware, @ptrCast(@alignCast(data))), request }, + ); + } else { + try @call( + .always_inline, + middleware.afterView, + .{request}, + ); + } + } + + if (request.state != .after_view) { + request.middleware_rendered = .{ + .name = @typeName(middleware), + .action = "afterView", + }; + break; + } + } +} + pub fn beforeResponse( middleware_data: *MiddlewareData, request: *jetzig.http.Request, diff --git a/src/jetzig/middleware.zig b/src/jetzig/middleware.zig index 4be3640..0da095c 100644 --- a/src/jetzig/middleware.zig +++ b/src/jetzig/middleware.zig @@ -5,6 +5,7 @@ pub const HtmxMiddleware = @import("middleware/HtmxMiddleware.zig"); pub const CompressionMiddleware = @import("middleware/CompressionMiddleware.zig"); pub const AuthMiddleware = @import("middleware/AuthMiddleware.zig"); pub const AntiCsrfMiddleware = @import("middleware/AntiCsrfMiddleware.zig"); +pub const InertiaMiddleware = @import("middleware/InertiaMiddleware.zig"); const RouteOptions = struct { content: ?[]const u8 = null, diff --git a/src/jetzig/middleware/InertiaMiddleware.zig b/src/jetzig/middleware/InertiaMiddleware.zig new file mode 100644 index 0000000..c124db7 --- /dev/null +++ b/src/jetzig/middleware/InertiaMiddleware.zig @@ -0,0 +1,38 @@ +const std = @import("std"); +const jetzig = @import("../../jetzig.zig"); + +const InertiaMiddleware = @This(); + +pub fn afterView(request: *jetzig.http.Request) !void { + if (request.headers.get("HX-Target")) |target| { + try request.server.logger.DEBUG( + "[middleware-htmx] htmx request detected, disabling layout. (#{s})", + .{target}, + ); + request.setLayout(null); + } else { + const template_context = jetzig.TemplateContext{ .request = request }; + const template = jetzig.zmpl.findPrefixed("jetzig", "inertia").?; + _ = request.renderContent(.ok, try template.render( + request.response_data, + jetzig.TemplateContext, + template_context, + .{}, + )); + } +} + +pub fn beforeResponse(request: *jetzig.http.Request, response: *jetzig.http.Response) !void { + switch (response.status_code) { + .moved_permanently, .found => {}, + else => return, + } + + if (request.headers.get("HX-Request") == null) return; + + if (response.headers.get("Location")) |location| { + response.status_code = .ok; + request.response_data.reset(); + try response.headers.append("HX-Redirect", location); + } +} diff --git a/src/jetzig/templates/inertia.zmpl b/src/jetzig/templates/inertia.zmpl new file mode 100644 index 0000000..38198ee --- /dev/null +++ b/src/jetzig/templates/inertia.zmpl @@ -0,0 +1,13 @@ + + + + My app + + + + + +
+ + + diff --git a/src/jetzig/views/View.zig b/src/jetzig/views/View.zig index 98beaac..f1fbefd 100644 --- a/src/jetzig/views/View.zig +++ b/src/jetzig/views/View.zig @@ -6,6 +6,7 @@ const jetzig = @import("../../jetzig.zig"); data: *jetzig.data.Data, status_code: jetzig.http.status_codes.StatusCode = .ok, +content: ?[]const u8 = null, pub fn deinit(self: Self) void { _ = self;