This commit is contained in:
Bob Farrell 2025-04-27 16:27:59 +01:00
parent ff6d8cf942
commit 3bd296821c
15 changed files with 263 additions and 165 deletions

View File

@ -25,8 +25,9 @@
.hash = "jetkv-0.0.0-zCv0fmCGAgCyYqwHjk0P5KrYVRew1MJAtbtAcIO-WPpT",
},
.zmpl = .{
.url = "https://github.com/jetzig-framework/zmpl/archive/9c54edd62b47aabdcb0e5f17beb45637b9de6a33.tar.gz",
.hash = "zmpl-0.0.1-SYFGBl5sAwDu45IUKKH3TQRc4qZ8A1P2nNaU4iO3Zf-6",
// .url = "https://github.com/jetzig-framework/zmpl/archive/89ee0ce9b4c96c316cc0575266fb66c864f24a49.tar.gz",
// .hash = "zmpl-0.0.1-SYFGBtuNAwCj2YbqnoEJt3bk1iFIZjGK6JwMc72toZBR",
.path = "../zmpl",
},
.httpz = .{
.url = "https://github.com/karlseguin/http.zig/archive/37d7cb9819b804ade5f4b974b82f8dd0622225ed.tar.gz",

View File

@ -6,10 +6,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/prism.css" />
{{context.middleware.renderHeader()}}
</head>
<body>
<main>{{zmpl.content}}</main>
<script src="/prism.js"></script>
{{context.middleware.renderFooter()}}
</body>
</html>

View File

@ -3,6 +3,8 @@ const jetzig = @import("jetzig");
const Game = @import("../lib/Game.zig");
pub const layout = "application";
pub fn index(request: *jetzig.Request) !jetzig.View {
return request.render(.ok);
}

View File

@ -1,143 +1,3 @@
<script>
const channel = {
websocket: null,
actions: {},
stateChangedCallbacks: [],
messageCallbacks: [],
invokeCallbacks: {},
elementMap: {},
transformerMap: {},
transformers: {},
onStateChanged: function(callback) { this.stateChangedCallbacks.push(callback); },
onMessage: function(callback) { this.messageCallbacks.push(callback); },
transform: function(ref, callback) {
if (Object.hasOwn(this.transformers, ref)) {
this.transformers[ref].push(callback);
} else {
this.transformers[ref] = [callback];
}
},
receive: function(ref, callback) {
if (Object.hasOwn(this.invokeCallbacks, ref)) {
this.invokeCallbacks[ref].push(callback);
} else {
this.invokeCallbacks[ref] = [callback];
}
},
publish: function(data) {
if (this.websocket) {
const json = JSON.stringify(data);
this.websocket.send(json);
}
},
};
(() => {
@if (context.request) |request|
const transform = (value, state, element) => {
const id = element.getAttribute('jetzig-id');
const key = id && channel.transformerMap[id];
const transformers = key && channel.transformers[key];
if (transformers) {
return transformers.reduce((acc, val) => val(acc), value);
} else {
return value === undefined || value == null ? '' : `${value}`
}
};
@if (request.headers.get("host")) |host|
channel.websocket = new WebSocket('ws://{{host}}{{request.path.base_path}}');
channel.websocket.addEventListener("message", (event) => {
const state_tag = "__jetzig_channel_state__:";
const actions_tag = "__jetzig_actions__:";
const event_tag = "__jetzig_event__:";
if (event.data.startsWith(state_tag)) {
const state = JSON.parse(event.data.slice(state_tag.length));
Object.entries(channel.elementMap).forEach(([ref, elements]) => {
const value = reduceState(ref, state);
elements.forEach(element => element.innerHTML = transform(value, state, element));
});
channel.stateChangedCallbacks.forEach((callback) => {
callback(state);
});
} else if (event.data.startsWith(event_tag)) {
const data = JSON.parse(event.data.slice(event_tag.length));
if (Object.hasOwn(channel.invokeCallbacks, data.method)) {
channel.invokeCallbacks[data.method].forEach(callback => {
callback(data);
});
}
} else if (event.data.startsWith(actions_tag)) {
const data = JSON.parse(event.data.slice(actions_tag.length));
data.actions.forEach(action => {
channel.actions[action.name] = (...params) => {
if (action.params.length != params.length) {
throw new Error(`Invalid params for action '${action.name}'`);
}
[...action.params].forEach((param, index) => {
const map = {
s: "string",
b: "boolean",
i: "number",
f: "number",
};
if (map[param] !== typeof params[index]) {
throw new Error(`Incorrect argument type for argument ${index} in '${action.name}'. Expected: ${map[param]}, found ${typeof params[index]}`);
}
});
channel.websocket.send(`_invoke:${action.name}:${JSON.stringify(params)}`);
};
});
} else {
const data = JSON.parse(event.data);
channel.messageCallbacks.forEach((callback) => {
callback(data);
});
}
});
const reduceState = (ref, state) => {
if (!ref.startsWith('$.')) throw new Error(`Unexpected ref format: ${ref}`);
const args = ref.split('.');
args.shift();
const isNumeric = (string) => [...string].every(char => '0123456789'.includes(char));
const isObject = (object) => object && typeof object === 'object';
return args.reduce((acc, arg) => {
if (isNumeric(arg)) {
if (acc && Array.isArray(acc) && acc.length > arg) return acc[parseInt(arg)];
return null;
} else {
if (acc && isObject(acc)) return acc[arg];
return null;
}
}, state);
};
window.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[jetzig-connect]').forEach(element => {
const ref = element.getAttribute('jetzig-connect');
if (!channel.elementMap[ref]) channel.elementMap[ref] = [];
const id = `jetzig-${crypto.randomUUID()}`;
element.setAttribute('jetzig-id', id);
channel.elementMap[ref].push(element);
channel.transformerMap[id] = element.getAttribute('jetzig-transform');
});
});
@// channel.websocket.addEventListener("open", (event) => {
@// // TODO
@// channel.publish("websockets", {});
@// });
@end
@end
})();
</script>
<div id="results-wrapper">
<span class="trophy">&#127942;</span>
<div id="results">
@ -155,7 +15,15 @@
<div class="board" id="board">
@for (0..9) |index| {
<div class="cell" jetzig-connect="$.cells.{{index}}" jetzig-transform="cell" id="tic-tac-toe-cell-{{index}}" data-cell="{{index}}"></div>
<div
class="cell"
jetzig-connect="$.cells.{{index}}"
jetzig-transform="cell"
jetzig-click="move"
id="tic-tac-toe-cell-{{index}}"
data-cell="{{index}}"
>
</div>
}
</div>
@ -167,20 +35,20 @@
<link rel="stylesheet" href="/party.css" />
<script>
channel.onStateChanged(state => {
jetzig.channel.onStateChanged(state => {
if (!state.victor) {
const element = document.querySelector("#victor");
element.style.visibility = 'hidden';
}
});
channel.onMessage(message => {
jetzig.channel.onMessage(message => {
if (message.err) {
console.log(message.err);
}
});
channel.receive("victor", (data) => {
jetzig.channel.receive("victor", (data) => {
const element = document.querySelector("#victor");
const emoji = {
player: "&#9992;&#65039;",
@ -192,24 +60,24 @@
triggerPartyAnimation();
});
channel.receive("game_over", (data) => {
jetzig.channel.receive("game_over", (data) => {
const element = document.querySelector("#board");
element.classList.remove('flash-animation');
void element.offsetWidth;
element.classList.add('flash-animation');
});
channel.transform("cell", (value) => (
jetzig.channel.transform("cell", (value) => (
{ player: "&#9992;&#65039;", cpu: "&#129422;" }[value] || ""
));
document.querySelectorAll("#board div.cell").forEach(element => {
element.addEventListener("click", () => {
channel.actions.move(parseInt(element.dataset.cell));
jetzig.channel.actions.move(parseInt(element.dataset.cell));
});
});
document.querySelector("#reset-button").addEventListener("click", () => {
channel.actions.reset();
jetzig.channel.actions.reset();
});
</script>

View File

@ -17,6 +17,7 @@ pub const jetzig_options = struct {
// jetzig.middleware.AuthMiddleware,
// jetzig.middleware.AntiCsrfMiddleware,
jetzig.middleware.HtmxMiddleware,
jetzig.middleware.ChannelsMiddleware,
// jetzig.middleware.InertiaMiddleware,
// jetzig.middleware.CompressionMiddleware,
// @import("app/middleware/DemoMiddleware.zig"),

View File

@ -28,16 +28,16 @@ const AppOptions = struct {
pub fn start(self: *const App, routes_module: type, options: AppOptions) !void {
defer self.env.deinit();
const action_router = comptime jetzig.channels.ActionRouter.initComptime(routes_module);
_ = action_router;
// inline for (action_router.actions) |action| std.debug.print("{s}\n", .{@typeName(action.params[0].type.?)});
if (self.initHook) |hook| try hook(@constCast(self));
var mime_map = jetzig.http.mime.MimeMap.init(self.allocator);
defer mime_map.deinit();
try mime_map.build();
inline for (jetzig.http.middleware.middlewares) |middleware| {
if (@hasDecl(middleware, "setup")) try middleware.setup(@constCast(self));
}
const routes = try createRoutes(self.allocator, if (@hasDecl(routes_module, "routes"))
&routes_module.routes
else

View File

@ -9,6 +9,31 @@ pub const TemplateContext = @This();
request: ?*http.Request = null,
route: ?views.Route = null,
middleware: Middleware = .{},
pub const Middleware = struct {
context: *TemplateContext = undefined,
pub inline fn renderHeader(middleware: Middleware) ![]const u8 {
return try middleware.render("header");
}
pub inline fn renderFooter(middleware: Middleware) ![]const u8 {
return try middleware.render("footer");
}
fn render(middleware: Middleware, comptime section: []const u8) ![]const u8 {
var buf = std.ArrayList(u8).init(middleware.context.*.request.?.allocator);
const writer = buf.writer();
inline for (http.middleware.middlewares) |middleware_type| {
if (@hasDecl(middleware_type, "Blocks") and @hasDecl(middleware_type.Blocks, section)) {
const renderFn = @field(middleware_type.Blocks, section);
try renderFn(middleware.context.*, writer);
}
}
return try buf.toOwnedSlice();
}
};
/// Return an authenticity token stored in the current request's session. If no token exists,
/// generate and store before returning.

View File

@ -17,6 +17,7 @@ pub const RequestState = enum {
after_request, // Initial middleware processing
after_view, // View returned, response data ready for full response render
rendered, // Rendered by middleware or view
rendered_content, // Rendered a plain string by middleware or view
redirected, // Redirected by middleware or view
failed, // Failed by middleware or view
before_response, // Post middleware processing
@ -249,8 +250,8 @@ pub fn renderContent(
) 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 };
self.state = .rendered_content;
return self.rendered_view.?;
}
@ -267,7 +268,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,
.after_view, .rendered, .redirected, .failed, .finalized => true,
.after_view, .rendered, .rendered_content, .redirected, .failed, .finalized => true,
};
}
@ -335,6 +336,9 @@ pub fn renderRedirect(self: *Request, state: RedirectState) !void {
var root = try self.response_data.root(.object);
try root.put("location", self.response_data.string(state.location));
var template_context = jetzig.TemplateContext{ .request = self };
template_context.middleware.context = &template_context;
const content = switch (self.requestFormat()) {
.HTML, .UNKNOWN => if (maybe_template) |template| blk: {
try view.data.addConst("jetzig_view", view.data.string("internal"));
@ -342,7 +346,7 @@ pub fn renderRedirect(self: *Request, state: RedirectState) !void {
break :blk try template.render(
self.response_data,
jetzig.TemplateContext,
.{ .request = self },
template_context,
&.{},
.{},
);

View File

@ -208,10 +208,13 @@ pub fn RoutedServer(Routes: type) type {
) !bool {
const route = self.matchChannelRoute(request.path.view_name) orelse return false;
const session = try request.session();
const session_id = session.getT(.string, "_id") orelse {
const session_id = session.getT(.string, "_id") orelse blk: {
try session.reset();
break :blk session.getT(.string, "_id") orelse {
try self.logger.ERROR("Error fetching session ID for websocket, aborting.", .{});
return false;
};
};
return try httpz.upgradeWebsocket(
jetzig.websockets.RoutedWebsocket(Routes),
@ -344,9 +347,7 @@ pub fn RoutedServer(Routes: type) type {
return request.setResponse(rendered_error, .{});
};
return if (request.state == .redirected or
request.state == .failed or
request.dynamic_assigned_template != null)
return if (request.isRendered() or request.dynamic_assigned_template != null)
request.setResponse(rendered, .{})
else
request.setResponse(try self.renderNotFound(request), .{});
@ -429,6 +430,10 @@ pub fn RoutedServer(Routes: type) type {
if (request.rendered_view) |rendered_view| {
if (request.state == .redirected) return .{ .view = rendered_view, .content = "" };
if (request.state == .rendered_content) return .{
.view = rendered_view,
.content = rendered_view.content.?,
};
if (template) |capture| {
return .{
@ -471,7 +476,8 @@ pub fn RoutedServer(Routes: type) type {
) ![]const u8 {
try addTemplateConstants(view, route);
const template_context = jetzig.TemplateContext{ .request = request };
var template_context = jetzig.TemplateContext{ .request = request };
template_context.middleware.context = &template_context;
if (request.getLayout(route)) |layout_name| {
// TODO: Allow user to configure layouts directory other than src/app/views/layouts/
@ -487,6 +493,7 @@ pub fn RoutedServer(Routes: type) type {
view.data,
jetzig.TemplateContext,
template_context,
&.{},
.{ .layout = layout },
);
} else {
@ -495,6 +502,7 @@ pub fn RoutedServer(Routes: type) type {
view.data,
jetzig.TemplateContext,
template_context,
&.{},
.{},
);
}
@ -666,6 +674,7 @@ pub fn RoutedServer(Routes: type) type {
request.response_data,
jetzig.TemplateContext,
.{ .request = request },
&.{},
.{},
),
};

View File

@ -6,6 +6,7 @@ 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");
pub const ChannelsMiddleware = @import("middleware/ChannelsMiddleware.zig");
const RouteOptions = struct {
content: ?[]const u8 = null,

View File

@ -0,0 +1,35 @@
const std = @import("std");
const jetzig = @import("../../jetzig.zig");
const ChannelsMiddleware = @This();
pub fn setup(app: *jetzig.App) !void {
app.route(.GET, "/_channels.js", ChannelsMiddleware, .renderChannels);
}
pub const Blocks = struct {
pub fn header(_: jetzig.TemplateContext, writer: anytype) !void {
try writer.writeAll(
\\<script src="/_channels.js"></script>
);
}
pub fn footer(context: jetzig.TemplateContext, writer: anytype) !void {
const request = context.request orelse return;
const host = request.headers.getLower("host") orelse return;
try writer.print(
\\<script>
\\ (() => {{
\\ window.addEventListener('DOMContentLoaded', () => {{
\\ jetzig.channel.init("{s}", "{s}");
\\ }});
\\ }})();
\\</script>
\\
, .{ host, request.path.base_path });
}
};
pub fn renderChannels(request: *jetzig.Request) !jetzig.View {
return request.renderContent(.ok, @embedFile("channels/channels.js"));
}

View File

@ -9,7 +9,7 @@ const HtmxMiddleware = @This();
/// content rendered directly by the view function.
pub fn afterRequest(request: *jetzig.http.Request) !void {
if (request.headers.get("HX-Request")) |_| {
try request.server.logger.DEBUG(
try request.logger.DEBUG(
"[middleware-htmx] HX-Request header, disabling layout.",
.{},
);

View File

@ -0,0 +1,145 @@
window.jetzig = window.jetzig ? window.jetzig : {}
jetzig = window.jetzig;
(() => {
const transform = (value, state, element) => {
const id = element.getAttribute('jetzig-id');
const key = id && jetzig.channel.transformerMap[id];
const transformers = key && jetzig.channel.transformers[key];
if (transformers) {
return transformers.reduce((acc, val) => val(acc), value);
} else {
return value === undefined || value == null ? '' : `${value}`
}
};
jetzig.channel = {
websocket: null,
actions: {},
stateChangedCallbacks: [],
messageCallbacks: [],
invokeCallbacks: {},
elementMap: {},
transformerMap: {},
transformers: {},
onStateChanged: function(callback) { this.stateChangedCallbacks.push(callback); },
onMessage: function(callback) { this.messageCallbacks.push(callback); },
init: function(host, path) {
console.log("here");
this.websocket = new WebSocket(`ws://${host}${path}`);
this.websocket.addEventListener("message", (event) => {
const state_tag = "__jetzig_channel_state__:";
const actions_tag = "__jetzig_actions__:";
const event_tag = "__jetzig_event__:";
if (event.data.startsWith(state_tag)) {
const state = JSON.parse(event.data.slice(state_tag.length));
Object.entries(this.elementMap).forEach(([ref, elements]) => {
const value = reduceState(ref, state);
elements.forEach(element => element.innerHTML = transform(value, state, element));
});
this.stateChangedCallbacks.forEach((callback) => {
callback(state);
});
} else if (event.data.startsWith(event_tag)) {
const data = JSON.parse(event.data.slice(event_tag.length));
if (Object.hasOwn(this.invokeCallbacks, data.method)) {
this.invokeCallbacks[data.method].forEach(callback => {
callback(data);
});
}
} else if (event.data.startsWith(actions_tag)) {
const data = JSON.parse(event.data.slice(actions_tag.length));
data.actions.forEach(action => {
this.actions[action.name] = {
callback: (...params) => {
if (action.params.length != params.length) {
throw new Error(`Invalid params for action '${action.name}'`);
}
[...action.params].forEach((param, index) => {
const map = {
s: "string",
b: "boolean",
i: "number",
f: "number",
};
if (map[param] !== typeof params[index]) {
throw new Error(`Incorrect argument type for argument ${index} in '${action.name}'. Expected: ${map[param]}, found ${typeof params[index]}`);
}
});
this.websocket.send(`_invoke:${action.name}:${JSON.stringify(params)}`);
},
spec: { ...action },
};
});
console.log(this.actions);
} else {
const data = JSON.parse(event.data);
this.messageCallbacks.forEach((callback) => {
callback(data);
});
}
});
const reduceState = (ref, state) => {
if (!ref.startsWith('$.')) throw new Error(`Unexpected ref format: ${ref}`);
const args = ref.split('.');
args.shift();
const isNumeric = (string) => [...string].every(char => '0123456789'.includes(char));
const isObject = (object) => object && typeof object === 'object';
return args.reduce((acc, arg) => {
if (isNumeric(arg)) {
if (acc && Array.isArray(acc) && acc.length > arg) return acc[parseInt(arg)];
return null;
} else {
if (acc && isObject(acc)) return acc[arg];
return null;
}
}, state);
};
document.querySelectorAll('[jetzig-connect]').forEach(element => {
const ref = element.getAttribute('jetzig-connect');
if (!this.elementMap[ref]) this.elementMap[ref] = [];
const id = `jetzig-${crypto.randomUUID()}`;
element.setAttribute('jetzig-id', id);
this.elementMap[ref].push(element);
this.transformerMap[id] = element.getAttribute('jetzig-transform');
});
document.querySelectorAll('[jetzig-click]').forEach(element => {
const ref = element.getAttribute('jetzig-click');
const action = this.actions[ref];
if (action) {
}
});
// this.websocket.addEventListener("open", (event) => {
// // TODO
// this.publish("websockets", {});
// });
},
transform: function(ref, callback) {
if (Object.hasOwn(this.transformers, ref)) {
this.transformers[ref].push(callback);
} else {
this.transformers[ref] = [callback];
}
},
receive: function(ref, callback) {
if (Object.hasOwn(this.invokeCallbacks, ref)) {
this.invokeCallbacks[ref].push(callback);
} else {
this.invokeCallbacks[ref] = [callback];
}
},
publish: function(data) {
if (this.websocket) {
const json = JSON.stringify(data);
this.websocket.send(json);
}
},
};
})();

View File

@ -0,0 +1,3 @@
@block head {
<script>console.log("hello");</script>
}

View File

@ -92,6 +92,8 @@ pub fn format(self: Route, _: []const u8, _: anytype, writer: anytype) !void {
pub fn match(self: Route, request: *const jetzig.http.Request) bool {
if (self.method != request.method) return false;
if (std.mem.eql(u8, request.path.file_path, self.uri_path)) return true;
var request_path_it = std.mem.splitScalar(u8, request.path.base_path, '/');
var uri_path_it = std.mem.splitScalar(u8, self.uri_path, '/');