This commit is contained in:
Bob Farrell 2025-04-27 22:16:12 +01:00
parent 3bd296821c
commit 94f67dd4a2
5 changed files with 186 additions and 41 deletions

View File

@ -27,7 +27,7 @@
}
</div>
<button id="reset-button">Reset Game</button>
<button jetzig-click="reset" id="reset-button">Reset Game</button>
<div id="victor"></div>
@ -70,14 +70,4 @@
jetzig.channel.transform("cell", (value) => (
{ player: "&#9992;&#65039;", cpu: "&#129422;" }[value] || ""
));
document.querySelectorAll("#board div.cell").forEach(element => {
element.addEventListener("click", () => {
jetzig.channel.actions.move(parseInt(element.dataset.cell));
});
});
document.querySelector("#reset-button").addEventListener("click", () => {
jetzig.channel.actions.reset();
});
</script>

View File

@ -12,6 +12,7 @@ buffer: std.ArrayList(u8),
dynamic_routes: std.ArrayList(Function),
static_routes: std.ArrayList(Function),
channel_routes: std.ArrayList([]const u8),
channel_actions: std.StringHashMap(std.StringHashMap([]const []const u8)),
module_paths: std.StringHashMap(void),
data: *jetzig.data.Data,
@ -124,6 +125,7 @@ pub fn init(
.static_routes = std.ArrayList(Function).init(allocator),
.dynamic_routes = std.ArrayList(Function).init(allocator),
.channel_routes = std.ArrayList([]const u8).init(allocator),
.channel_actions = std.StringHashMap(std.StringHashMap([]const []const u8)).init(allocator),
.module_paths = std.StringHashMap(void).init(allocator),
.data = data,
};
@ -163,6 +165,7 @@ pub fn generateRoutes(self: *Routes) ![]const u8 {
\\});
\\
);
try writer.writeAll(
\\
\\pub const mailers = [_]jetzig.MailerDefinition{
@ -410,10 +413,35 @@ fn writeChannelRoutes(self: *Routes, writer: anytype) !void {
defer self.allocator.free(relative_path);
const view_name = chompExtension(relative_path);
var actions_buf = std.ArrayList(u8).init(self.allocator);
const actions_writer = actions_buf.writer();
if (self.channel_actions.get(path)) |actions| {
var it = actions.iterator();
while (it.next()) |entry| {
try actions_writer.print(
\\.{{ .name = "{s}", .params = &.{{
, .{entry.key_ptr.*});
for (entry.value_ptr.*, 0..) |param, index| {
if (index == 0) continue; // Skip `self` argument.
try actions_writer.print(
\\.{{ .name = "{s}" }},
, .{param});
}
try actions_writer.writeAll("},},");
}
}
try writer.print(
\\.{{ "{0s}", jetzig.channels.Route.initComptime(@import("{1s}"), "{0s}") }}
\\.{{
\\ "{0s}",
\\ jetzig.channels.Route.initComptime(
\\ @import("{1s}"),
\\ "{0s}",
\\ &.{{{2s}}}
\\ ),
\\}}
\\
, .{ view_name, module_path });
, .{ view_name, module_path, actions_buf.items });
}
}
@ -496,6 +524,7 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout
const decl_name = self.ast.tokenSlice(container_token - 2);
if (std.mem.eql(u8, decl_name, "Channel")) {
try channel_routes.append(path);
try self.parseChannel(container, path);
}
},
else => {},
@ -527,6 +556,83 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout
};
}
// Although we mostly evaluate channel routes at comptime, we need to parse the function
// signatures in `Actions` to get argument names (Zig only reflects the types).
fn parseChannel(self: *Routes, channel: std.zig.Ast.full.ContainerDecl, path: []const u8) !void {
for (channel.ast.members) |member| {
const tag = self.ast.nodeTag(member);
switch (tag) {
.simple_var_decl => {
const var_decl = self.ast.simpleVarDecl(member);
const var_name = self.ast.tokenSlice(self.ast.nodeMainToken(member) + 1);
if (std.mem.eql(u8, var_name, "Actions")) {
const init_node = var_decl.ast.init_node.unwrap() orelse continue;
switch (self.ast.nodeTag(init_node)) {
.container_decl_two,
.container_decl_two_trailing,
.container_decl,
.container_decl_trailing,
=> |container_tag| {
var buf: [2]std.zig.Ast.Node.Index = undefined;
const container = switch (container_tag) {
.container_decl_two,
.container_decl_two_trailing,
=> self.ast.containerDeclTwo(&buf, init_node),
.container_decl,
.container_decl_trailing,
=> self.ast.containerDecl(init_node),
else => unreachable,
};
const container_token = container.ast.main_token;
const decl_name = self.ast.tokenSlice(container_token - 2);
if (std.mem.eql(u8, decl_name, "Actions")) {
try self.parseChannelActions(container, path);
}
},
else => continue,
}
}
},
else => {},
}
}
}
fn parseChannelActions(self: *Routes, actions: std.zig.Ast.full.ContainerDecl, path: []const u8) !void {
for (actions.ast.members) |member| {
const tag = self.ast.nodes.items(.tag)[@intFromEnum(member)];
switch (tag) {
.fn_proto,
.fn_proto_multi,
.fn_proto_one,
.fn_proto_simple,
.fn_decl,
=> {
var buf: [1]std.zig.Ast.Node.Index = undefined;
const func = self.ast.fullFnProto(&buf, member).?;
const visib_token = func.visib_token orelse continue;
if (!std.mem.eql(u8, self.ast.tokenSlice(visib_token), "pub")) continue;
const func_name_token = func.name_token orelse continue;
const func_name = self.ast.tokenSlice(func_name_token);
var params_buf = std.ArrayList([]const u8).init(self.allocator);
var params_it = func.iterate(&self.ast);
while (params_it.next()) |param| {
try params_buf.append(
try self.allocator.dupe(u8, self.ast.tokenSlice(param.name_token.?)),
);
}
const result = try self.channel_actions.getOrPut(path);
if (!result.found_existing) {
result.value_ptr.* = std.StringHashMap([]const []const u8).init(self.allocator);
}
try result.value_ptr.put(try self.allocator.dupe(u8, func_name), try params_buf.toOwnedSlice());
},
else => {},
}
}
}
// Parse the `pub const static_params` definition and into a `jetzig.data.Value`.
fn parseStaticParamsDecl(self: *Routes, decl: std.zig.Ast.full.VarDecl, params: *jetzig.data.Value) !void {
const init_node = decl.ast.init_node.unwrap() orelse return;

View File

@ -148,7 +148,12 @@ fn encodeParams(Routes: type) !std.StaticStringMap([]const u8) {
actions: []ActionSpec,
pub const ActionSpec = struct {
name: []const u8,
params: []const u8,
params: []const ParamSpec,
pub const ParamSpec = struct {
type: []const u8,
name: []const u8,
};
};
};
const Tuple = std.meta.Tuple(&.{ []const u8, []const u8 });
@ -168,10 +173,17 @@ fn encodeParams(Routes: type) !std.StaticStringMap([]const u8) {
switch (@typeInfo(@TypeOf(@field(view.module.Channel.Actions, decl.name)))) {
.@"fn" => |info| {
verifyParams(info.params, view.name, decl.name);
const route = Routes.channel_routes.get(view.name).?;
const action = for (route.actions) |action| {
if (std.mem.eql(u8, action.name, decl.name)) break action;
} else unreachable;
if (info.params.len > 1) {
var params: [info.params.len - 1]u8 = undefined;
var params: [info.params.len - 1]Spec.ActionSpec.ParamSpec = undefined;
for (info.params[1..], 0..) |param, param_index| {
params[param_index] = jsonTypeName(param.type.?);
params[param_index] = .{
.type = jsonTypeName(param.type.?),
.name = action.params[param_index].name,
};
}
actions[decl_index] = .{ .name = decl.name, .params = &params };
} else {
@ -210,13 +222,13 @@ fn verifyParams(
if (params[0].type.? != jetzig.channels.Channel) @compileError(missing_param);
}
fn jsonTypeName(T: type) u8 {
fn jsonTypeName(T: type) []const u8 {
return switch (T) {
[]const u8 => 's',
[]const u8 => "string",
else => switch (@typeInfo(T)) {
.float, .comptime_float => 'f',
.bool => 'b',
.int, .comptime_int => 'i',
.float, .comptime_float => "float",
.int, .comptime_int => "integer",
.bool => "bool",
else => @compileError("Unsupported Channel Action argument type: " ++ @typeName(T)),
},
};

View File

@ -5,12 +5,22 @@ const Route = @This();
receiveMessageFn: ?*const fn (jetzig.channels.Message) anyerror!void = null,
openConnectionFn: ?*const fn (jetzig.channels.Channel) anyerror!void = null,
path: []const u8,
actions: []const Action,
pub const Action = struct {
name: []const u8,
params: []const Param,
pub const Param = struct {
name: []const u8,
};
};
pub fn receiveMessage(route: Route, message: jetzig.channels.Message) !void {
if (route.receiveMessageFn) |func| try func(message);
}
pub fn initComptime(T: type, path: []const u8) Route {
pub fn initComptime(T: type, path: []const u8, actions: []const Action) Route {
comptime {
if (!@hasDecl(T, "Channel")) return .{};
const openConnectionFn = if (@hasDecl(T.Channel, "open")) T.Channel.open else null;
@ -20,6 +30,7 @@ pub fn initComptime(T: type, path: []const u8) Route {
.openConnectionFn = openConnectionFn,
.receiveMessageFn = receiveMessageFn,
.path = path,
.actions = actions,
};
}
}

View File

@ -16,6 +16,7 @@ jetzig = window.jetzig;
jetzig.channel = {
websocket: null,
actions: {},
action_specs: {},
stateChangedCallbacks: [],
messageCallbacks: [],
invokeCallbacks: {},
@ -25,7 +26,6 @@ jetzig = window.jetzig;
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__:";
@ -51,20 +51,33 @@ jetzig = window.jetzig;
} 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] = {
this.action_specs[action.name] = {
callback: (...params) => {
if (action.params.length != params.length) {
throw new Error(`Invalid params for action '${action.name}'`);
throw new Error(`Invalid params for action '${action.name}'. Expected ${action.params.length} params, found ${params.length}`);
}
[...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]}`);
if (param.type !== typeof params[index]) {
const err = `Incorrect argument type for argument ${index} in '${action.name}'. Expected: ${param.type}, found ${typeof params[index]}`;
switch (param.type) {
case "string":
params[index] = `${params[index]}`;
break;
case "integer":
try { params[index] = parseInt(params[index]) } catch {
throw new Error(err);
};
break;
case "float":
try { params[index] = parseFloat(params[index]) } catch {
throw new Error(err);
};
case "boolean":
params[index] = ["true", "y", "1", "yes", "t"].includes(params[index]);
break;
default:
throw new Error(err);
}
}
});
@ -72,8 +85,28 @@ jetzig = window.jetzig;
},
spec: { ...action },
};
this.actions[action.name] = this.action_specs[action.name].callback;
});
document.querySelectorAll('[jetzig-click]').forEach(element => {
const ref = element.getAttribute('jetzig-click');
const action = this.action_specs[ref];
if (action) {
element.addEventListener('click', () => {
const args = [];
action.spec.params.forEach(param => {
const arg = element.dataset[param.name];
if (arg === undefined) {
throw new Error(`Expected 'data-${param.name}' attribute for '${action.name}' click handler.`);
} else {
args.push(element.dataset[param.name]);
}
});
action.callback(...args);
});
} else {
throw new Error(`Unknown click handler: '${ref}'`);
}
});
console.log(this.actions);
} else {
const data = JSON.parse(event.data);
this.messageCallbacks.forEach((callback) => {
@ -108,13 +141,6 @@ jetzig = window.jetzig;
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