Merge pull request #76 from jetzig-framework/template-inheritance-and-dynamic-templates

Update Zmpl for template inheritance
This commit is contained in:
bobf 2024-05-26 17:25:38 +01:00 committed by GitHub
commit 4b93069daf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 145 additions and 29 deletions

View File

@ -7,8 +7,8 @@
.hash = "12203b56c2e17a2fd62ea3d3d9be466f43921a3aef88b381cf58f41251815205fdb5",
},
.zmpl = .{
.url = "https://github.com/jetzig-framework/zmpl/archive/918b74353c5680b54b435dcacd67268b23ffa129.tar.gz",
.hash = "1220bf01968a822771a33bb51e37ff8ee13d21437f95ec55150ffa7c6a9fb1dfcbc5",
.url = "https://github.com/jetzig-framework/zmpl/archive/aa4d8ad5b63976d96e3b2c187f5b0b2c693905a1.tar.gz",
.hash = "1220b6dfedaf6ad2b464ebcec2aafdc01ba593a07a53885033d56c50a5a04334b517",
},
.jetkv = .{
.url = "https://github.com/jetzig-framework/jetkv/archive/78bcdcc6b0cbd3ca808685c64554a15701f13250.tar.gz",

View File

@ -24,7 +24,6 @@ pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
const params = try request.params();
try root.put("param", params.get("foo").?);
std.debug.print("{}\n", .{params});
return request.render(.ok);
}

View File

@ -44,7 +44,9 @@ pub const View = views.View;
/// A route definition. Generated at build type by `Routes.zig`.
pub const Route = views.Route;
const root = @import("root");
/// A middleware route definition. Allows middleware to define custom routes in order to serve
/// content.
pub const MiddlewareRoute = middleware.MiddlewareRoute;
/// An asynchronous job that runs outside of the request/response flow. Create via `Request.job`
/// and set params with `Job.put`, then call `Job.schedule()` to add to the
@ -61,6 +63,8 @@ pub const MailerDefinition = mail.MailerDefinition;
/// `ERROR`, etc.). Note that all log functions are CAPITALIZED.
pub const Logger = loggers.Logger;
const root = @import("root");
/// Global configuration. Override these values by defining in `src/main.zig` with:
/// ```zig
/// pub const jetzig_options = struct {

View File

@ -181,15 +181,25 @@ pub fn route(
.method = method,
.view_name = self.allocator.dupe(u8, &view_name) catch @panic("OOM"),
.uri_path = path,
.view = comptime switch (isIncludeId(path)) {
true => .{ .custom = .{ .with_id = viewFn } },
false => .{ .custom = .{ .without_id = viewFn } },
.layout = if (@hasDecl(module, "layout")) module.layout else null,
.view = comptime switch (viewType(path)) {
.with_id => .{ .custom = .{ .with_id = viewFn } },
.with_args => .{ .custom = .{ .with_args = viewFn } },
.without_id => .{ .custom = .{ .without_id = viewFn } },
},
.template = self.allocator.dupe(u8, &template) catch @panic("OOM"),
.json_params = &.{},
}) catch @panic("OOM");
}
inline fn isIncludeId(comptime path: []const u8) bool {
return std.mem.containsAtLeast(u8, path, 1, "/:");
inline fn viewType(path: []const u8) enum { with_id, without_id, with_args } {
var it = std.mem.tokenizeSequence(u8, path, "/");
while (it.next()) |segment| {
if (std.mem.startsWith(u8, segment, ":")) {
if (std.mem.endsWith(u8, segment, "*")) return .with_args;
return .with_id;
}
}
return .without_id;
}

View File

@ -54,6 +54,30 @@ pub fn resourceId(self: Self, route: jetzig.views.Route) []const u8 {
return self.resource_id;
}
pub fn resourceArgs(self: Self, route: jetzig.views.Route, allocator: std.mem.Allocator) ![]const []const u8 {
var args = std.ArrayList([]const u8).init(allocator);
var route_uri_path_it = std.mem.splitScalar(u8, route.uri_path, '/');
var path_it = std.mem.splitScalar(u8, self.base_path, '/');
var matched = false;
while (path_it.next()) |path_segment| {
const route_uri_path_segment = route_uri_path_it.next();
if (!matched and
route_uri_path_segment != null and
std.mem.startsWith(u8, route_uri_path_segment.?, ":") and
std.mem.endsWith(u8, route_uri_path_segment.?, "*"))
{
matched = true;
}
if (matched) {
try args.append(path_segment);
}
}
return try args.toOwnedSlice();
}
// Extract `"/foo/bar/baz"` from:
// * `"/foo/bar/baz"`
// * `"/foo/bar/baz.html"`

View File

@ -27,6 +27,7 @@ _cookies: ?*jetzig.http.Cookies = null,
_session: ?*jetzig.http.Session = null,
body: []const u8 = undefined,
processed: bool = false,
dynamic_assigned_template: ?[]const u8 = null,
layout: ?[]const u8 = null,
layout_disabled: bool = false,
rendered: bool = false,
@ -513,6 +514,21 @@ pub fn formatStatus(self: *const Request, status_code: jetzig.http.StatusCode) !
};
}
/// Override default template name for a matched route.
pub fn setTemplate(self: *Request, name: []const u8) void {
self.dynamic_assigned_template = name;
}
pub fn joinPaths(self: *const Request, paths: []const []const []const u8) ![]const u8 {
var buf = std.ArrayList([]const u8).init(self.allocator);
defer buf.deinit();
for (paths) |subpaths| {
for (subpaths) |path| try buf.append(path);
}
return try std.mem.join(self.allocator, "/", buf.items);
}
pub fn setResponse(
self: *Request,
rendered_view: jetzig.http.Server.RenderedView,

View File

@ -166,6 +166,17 @@ fn renderResponse(self: *Server, request: *jetzig.http.Request) !void {
return;
}
if (matchMiddlewareRoute(request)) |route| {
if (route.content) |content| {
const rendered: RenderedView = .{
.view = .{ .data = request.response_data, .status_code = route.status },
.content = content,
};
request.setResponse(rendered, .{ .content_type = route.content_type });
return;
} else unreachable; // In future a MiddlewareRoute might provide a render function etc.
}
const route = self.matchCustomRoute(request) orelse try self.matchRoute(request, false);
switch (request.requestFormat()) {
@ -196,19 +207,21 @@ fn renderHTML(
};
return request.setResponse(rendered, .{});
} else {
// Try rendering without a template to see if we get a redirect.
// Try rendering without a template to see if we get a redirect or a template
// assigned in a view.
const rendered = self.renderView(matched_route, request, null) catch |err| {
if (isUnhandledError(err)) return err;
const rendered_error = try self.renderInternalServerError(request, err);
return request.setResponse(rendered_error, .{});
};
return if (request.redirected)
return if (request.redirected or request.dynamic_assigned_template != null)
request.setResponse(rendered, .{})
else
request.setResponse(try self.renderNotFound(request), .{});
}
} else {
// If no matching route found, try to render a Markdown file in views directory.
if (try self.renderMarkdown(request)) |rendered| {
return request.setResponse(rendered, .{});
} else {
@ -259,7 +272,7 @@ fn renderView(
self: *Server,
route: jetzig.views.Route,
request: *jetzig.http.Request,
template: ?zmpl.Template,
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
@ -271,6 +284,11 @@ fn renderView(
return try self.renderInternalServerError(request, err);
};
const template: ?zmpl.Template = if (request.dynamic_assigned_template) |request_template|
zmpl.findPrefixed("views", request_template) orelse maybe_template
else
maybe_template;
if (request.rendered_multiple) return error.JetzigMultipleRenderError;
if (request.rendered_view) |rendered_view| {
@ -507,6 +525,20 @@ fn matchCustomRoute(self: Server, request: *const jetzig.http.Request) ?jetzig.v
return null;
}
fn matchMiddlewareRoute(request: *const jetzig.http.Request) ?jetzig.middleware.MiddlewareRoute {
const middlewares = jetzig.config.get([]const type, "middleware");
inline for (middlewares) |middleware| {
if (@hasDecl(middleware, "routes")) {
inline for (middleware.routes) |route| {
if (route.match(request)) return route;
}
}
}
return null;
}
fn matchRoute(self: *Server, request: *jetzig.http.Request, static: bool) !?jetzig.views.Route {
for (self.routes) |route| {
// .index routes always take precedence.

View File

@ -1 +1,35 @@
const std = @import("std");
const jetzig = @import("../jetzig.zig");
pub const HtmxMiddleware = @import("middleware/HtmxMiddleware.zig");
const RouteOptions = struct {
content: ?[]const u8 = null,
content_type: []const u8 = "text/html",
status: jetzig.http.StatusCode = .ok,
};
pub const MiddlewareRoute = struct {
method: jetzig.http.Request.Method,
path: []const u8,
content: ?[]const u8,
content_type: []const u8,
status: jetzig.http.StatusCode,
pub fn match(self: MiddlewareRoute, request: *const jetzig.http.Request) bool {
if (self.method != request.method) return false;
if (!std.mem.eql(u8, self.path, request.path.file_path)) return false;
return true;
}
};
pub fn route(method: jetzig.http.Request.Method, path: []const u8, options: RouteOptions) MiddlewareRoute {
return .{
.method = method,
.path = path,
.content = options.content,
.content_type = options.content_type,
.status = options.status,
};
}

View File

@ -1,20 +1,13 @@
const std = @import("std");
const jetzig = @import("../../jetzig.zig");
const Self = @This();
/// Initialize htmx middleware.
pub fn init(request: *jetzig.http.Request) !*Self {
const middleware = try request.allocator.create(Self);
return middleware;
}
const HtmxMiddleware = @This();
/// Detects the `HX-Request` header and, if present, disables the default layout for the current
/// request. This allows a view to specify a layout that will render the full page when the
/// request doesn't come via htmx and, when the request does come from htmx, only return the
/// content rendered directly by the view function.
pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void {
_ = self;
pub fn afterRequest(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})",
@ -26,8 +19,7 @@ pub fn afterRequest(self: *Self, 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(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void {
_ = self;
pub fn beforeResponse(request: *const jetzig.http.Request, response: *jetzig.http.Response) !void {
if (response.status_code != .moved_permanently and response.status_code != .found) return;
if (request.headers.get("HX-Request") == null) return;
@ -37,8 +29,3 @@ pub fn beforeResponse(self: *Self, request: *jetzig.http.Request, response: *jet
try response.headers.append("HX-Redirect", location);
}
}
/// Clean up the allocated htmx middleware.
pub fn deinit(self: *Self, request: *jetzig.http.Request) void {
request.allocator.destroy(self);
}

View File

@ -11,6 +11,7 @@ pub const RenderStaticFn = *const fn (Route, *jetzig.http.StaticRequest) anyerro
pub const ViewWithoutId = *const fn (*jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View;
pub const ViewWithId = *const fn (id: []const u8, *jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View;
const StaticViewWithoutId = *const fn (*jetzig.http.StaticRequest, *jetzig.data.Data) anyerror!jetzig.views.View;
pub const ViewWithArgs = *const fn ([]const []const u8, *jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View;
const StaticViewWithId = *const fn (id: []const u8, *jetzig.http.StaticRequest, *jetzig.data.Data) anyerror!jetzig.views.View;
pub const DynamicViewType = union(Action) {
@ -36,6 +37,7 @@ pub const StaticViewType = union(Action) {
pub const CustomViewType = union(enum) {
with_id: ViewWithId,
without_id: ViewWithoutId,
with_args: ViewWithArgs,
};
pub const ViewType = union(enum) {
@ -87,7 +89,10 @@ pub fn match(self: Route, request: *const jetzig.http.Request) bool {
while (uri_path_it.next()) |expected_segment| {
const actual_segment = request_path_it.next() orelse return false;
if (std.mem.startsWith(u8, expected_segment, ":")) continue;
if (std.mem.startsWith(u8, expected_segment, ":")) {
if (std.mem.endsWith(u8, expected_segment, "*")) return true;
continue;
}
if (!std.mem.eql(u8, expected_segment, actual_segment)) return false;
}
@ -100,6 +105,11 @@ fn renderFn(self: Route, request: *jetzig.http.Request) anyerror!jetzig.views.Vi
.custom => |view_type| switch (view_type) {
.with_id => |view| return try view(request.path.resourceId(self), request, request.response_data),
.without_id => |view| return try view(request, request.response_data),
.with_args => |view| return try view(
try request.path.resourceArgs(self, request.allocator),
request,
request.response_data,
),
},
// We only end up here if a static route is defined but its output is not found in the
// file system (e.g. if it was manually deleted after build). This should be avoidable by