mirror of
https://github.com/ziex-dev/ziex.git
synced 2026-07-21 02:59:36 -06:00
refactor(core): make root.zig only an entrypoint
This commit is contained in:
parent
a41e192a3f
commit
1d926754aa
326
src/Component.zig
Normal file
326
src/Component.zig
Normal file
@ -0,0 +1,326 @@
|
||||
const std = @import("std");
|
||||
|
||||
const zx = @import("root.zig");
|
||||
const prp = @import("props.zig");
|
||||
|
||||
const ElementTag = zx.ElementTag;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const BuiltinAttribute = zx.BuiltinAttribute;
|
||||
const devtool = zx.devtool;
|
||||
|
||||
pub const Component = union(enum) {
|
||||
pub const Serializable = devtool.ComponentSerializable;
|
||||
|
||||
none,
|
||||
text: []const u8,
|
||||
element: Element,
|
||||
component_fn: ComponentFn,
|
||||
component_csr: ComponentCsr,
|
||||
/// Reactive signal text - updates automatically when signal changes
|
||||
signal_text: SignalText,
|
||||
|
||||
/// A text node bound to a Signal for fine-grained reactivity
|
||||
pub const SignalText = struct {
|
||||
/// The signal's unique ID for DOM binding
|
||||
signal_id: u64,
|
||||
/// The current text value (for initial render)
|
||||
current_text: []const u8,
|
||||
};
|
||||
|
||||
pub const ComponentCsr = struct {
|
||||
name: []const u8,
|
||||
id: []const u8,
|
||||
props_ptr: ?*const anyopaque = null,
|
||||
writeProps: ?*const fn (*std.Io.Writer, *const anyopaque) anyerror!void = null,
|
||||
getStateItems: ?*const anyopaque = null,
|
||||
/// SSR-rendered content of the component (for hydration)
|
||||
children: ?*const Component = null,
|
||||
/// Whether this is a React component (uses JSON) or Zig component (uses ZON)
|
||||
is_react: bool = false,
|
||||
};
|
||||
|
||||
pub const ComponentFn = struct {
|
||||
propsPtr: ?*const anyopaque,
|
||||
callFn: *const fn (propsPtr: ?*const anyopaque, allocator: Allocator) anyerror!Component,
|
||||
getStateItems: ?*const anyopaque = null,
|
||||
allocator: Allocator,
|
||||
deinitFn: ?*const fn (propsPtr: ?*const anyopaque, allocator: Allocator) void,
|
||||
async_mode: BuiltinAttribute.Async = .sync,
|
||||
fallback: ?*const Component = null,
|
||||
caching: ?BuiltinAttribute.Caching = null,
|
||||
name: []const u8,
|
||||
|
||||
pub fn init(comptime func: anytype, name: []const u8, allocator: Allocator, props: anytype) ComponentFn {
|
||||
const FuncInfo = @typeInfo(@TypeOf(func));
|
||||
const param_count = FuncInfo.@"fn".params.len;
|
||||
const fn_name = @typeName(@TypeOf(func));
|
||||
|
||||
// Validation of parameters
|
||||
if (param_count != 1 and param_count != 2)
|
||||
@compileError(std.fmt.comptimePrint("{s} must have 1 or 2 parameters found {d} parameters", .{ fn_name, param_count }));
|
||||
|
||||
const FirstPropType = FuncInfo.@"fn".params[0].type.?;
|
||||
const first_is_allocator = FirstPropType == std.mem.Allocator;
|
||||
const first_is_ctx_ptr = @typeInfo(FirstPropType) == .pointer and
|
||||
@hasField(@typeInfo(FirstPropType).pointer.child, "allocator") and
|
||||
@hasField(@typeInfo(FirstPropType).pointer.child, "children");
|
||||
|
||||
if (!first_is_allocator and !first_is_ctx_ptr)
|
||||
@compileError("Component " ++ fn_name ++ " must have allocator or *ComponentCtx as the first parameter");
|
||||
|
||||
// If two parameters are passed with allocator first, the props type must be a struct
|
||||
if (first_is_allocator and param_count == 2) {
|
||||
const SecondPropType = FuncInfo.@"fn".params[1].type.?;
|
||||
if (@typeInfo(SecondPropType) != .@"struct")
|
||||
@compileError("Component" ++ fn_name ++ " must have a struct as the second parameter, found " ++ @typeName(SecondPropType));
|
||||
}
|
||||
|
||||
// Context-based components should only have 1 parameter
|
||||
if (first_is_ctx_ptr and param_count != 1)
|
||||
@compileError("Component " ++ fn_name ++ " with *ComponentCtx must have exactly 1 parameter");
|
||||
|
||||
// Allocate props on heap to persist
|
||||
const props_copy = if (first_is_allocator and param_count == 2) blk: {
|
||||
const SecondPropType = FuncInfo.@"fn".params[1].type.?;
|
||||
const coerced = prp.coerceProps(SecondPropType, props);
|
||||
const p = allocator.create(SecondPropType) catch @panic("OOM");
|
||||
p.* = coerced;
|
||||
break :blk p;
|
||||
} else if (first_is_ctx_ptr) blk: {
|
||||
// Contexted components
|
||||
const CtxType = @typeInfo(FirstPropType).pointer.child;
|
||||
const ctx = allocator.create(CtxType) catch @panic("OOM");
|
||||
ctx.allocator = allocator;
|
||||
// Children from props if present
|
||||
ctx.children = if (@hasField(@TypeOf(props), "children")) props.children else null;
|
||||
// fn Component(ctx: *ComponentCtx(Props)) zx.Component
|
||||
if (@hasField(CtxType, "props")) {
|
||||
const PropsFieldType = @FieldType(CtxType, "props");
|
||||
if (PropsFieldType != void) {
|
||||
ctx.props = prp.coerceProps(PropsFieldType, props);
|
||||
}
|
||||
}
|
||||
break :blk ctx;
|
||||
} else null;
|
||||
|
||||
const Wrapper = struct {
|
||||
// Check if the function returns an optional type
|
||||
const ReturnType = FuncInfo.@"fn".return_type.?;
|
||||
const returns_optional = @typeInfo(ReturnType) == .optional;
|
||||
const returns_error_union = @typeInfo(ReturnType) == .error_union;
|
||||
const inner_is_optional = returns_error_union and @typeInfo(@typeInfo(ReturnType).error_union.payload) == .optional;
|
||||
|
||||
/// Normalize any return type (Component, ?Component, !Component, !?Component) to anyerror!Component
|
||||
fn normalize(result: anytype) anyerror!Component {
|
||||
const T = @TypeOf(result);
|
||||
if (T == Component) {
|
||||
return result;
|
||||
}
|
||||
// ?Component -> return .none if null
|
||||
if (@typeInfo(T) == .optional) {
|
||||
return result orelse .none;
|
||||
}
|
||||
// !Component or !?Component
|
||||
if (@typeInfo(T) == .error_union) {
|
||||
const payload = try result;
|
||||
// Check if payload is optional
|
||||
if (@typeInfo(@TypeOf(payload)) == .optional) {
|
||||
return payload orelse .none;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
fn call(propsPtr: ?*const anyopaque, alloc: Allocator) anyerror!Component {
|
||||
if (first_is_ctx_ptr) {
|
||||
const CtxType = @typeInfo(FirstPropType).pointer.child;
|
||||
const ctx_ptr: *CtxType = @ptrCast(@alignCast(@constCast(propsPtr orelse @panic("ctx is null"))));
|
||||
return normalize(func(ctx_ptr));
|
||||
}
|
||||
if (first_is_allocator and param_count == 1) {
|
||||
return normalize(func(alloc));
|
||||
}
|
||||
if (first_is_allocator and param_count == 2) {
|
||||
const SecondPropType = FuncInfo.@"fn".params[1].type.?;
|
||||
const p = propsPtr orelse @panic("propsPtr is null for function with props");
|
||||
const typed_p: *const SecondPropType = @ptrCast(@alignCast(p));
|
||||
return normalize(func(alloc, typed_p.*));
|
||||
}
|
||||
unreachable;
|
||||
}
|
||||
|
||||
fn deinit(propsPtr: ?*const anyopaque, alloc: Allocator) void {
|
||||
if (first_is_ctx_ptr) {
|
||||
const CtxType = @typeInfo(FirstPropType).pointer.child;
|
||||
const ctx_ptr: *CtxType = @ptrCast(@alignCast(@constCast(propsPtr orelse return)));
|
||||
alloc.destroy(ctx_ptr);
|
||||
return;
|
||||
}
|
||||
if (first_is_allocator and param_count == 2) {
|
||||
const SecondPropType = FuncInfo.@"fn".params[1].type.?;
|
||||
const p = propsPtr orelse @panic("propsPtr is null for function with props");
|
||||
const typed_p: *const SecondPropType = @ptrCast(@alignCast(p));
|
||||
alloc.destroy(typed_p);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return .{
|
||||
.propsPtr = props_copy,
|
||||
.callFn = Wrapper.call,
|
||||
.getStateItems = @ptrCast(devtool.ComponentSerializable.createGetStateItemsFn(func)),
|
||||
.allocator = allocator,
|
||||
.deinitFn = Wrapper.deinit,
|
||||
.name = name,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn call(self: ComponentFn) anyerror!Component {
|
||||
return self.callFn(self.propsPtr, self.allocator);
|
||||
}
|
||||
|
||||
pub fn deinit(self: ComponentFn) void {
|
||||
if (self.deinitFn) |deinit_fn| {
|
||||
deinit_fn(self.propsPtr, self.allocator);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Free allocated memory recursively
|
||||
/// Note: Only frees what was allocated by ZxContext.zx()
|
||||
/// Inline struct data is not freed (and will cause no issues as it's stack data)
|
||||
pub fn deinit(self: Component, allocator: std.mem.Allocator) void {
|
||||
switch (self) {
|
||||
.none, .text, .signal_text => {},
|
||||
.element => |elem| {
|
||||
if (elem.children) |children| {
|
||||
// Recursively free children (e.g., Button() results)
|
||||
for (children) |child| {
|
||||
child.deinit(allocator);
|
||||
}
|
||||
// Free the children array itself
|
||||
allocator.free(children);
|
||||
}
|
||||
if (elem.attributes) |attributes| {
|
||||
allocator.free(attributes);
|
||||
}
|
||||
},
|
||||
.component_fn => |func| {
|
||||
// Free the props that were allocated
|
||||
func.deinit();
|
||||
},
|
||||
.component_csr => |component_csr| {
|
||||
allocator.free(component_csr.name);
|
||||
allocator.free(component_csr.id);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: Move these to runtime/server
|
||||
pub const render = @import("runtime/server/render.zig").render;
|
||||
pub const stream = @import("runtime/server/render.zig").stream;
|
||||
|
||||
/// Recursively search for an element by tag name
|
||||
/// Returns a mutable pointer to the Component if found, null otherwise
|
||||
/// Note: Resolves component_fn lazily during search
|
||||
/// Note: Requires allocator to make children mutable if needed
|
||||
pub fn getElementByName(self: *Component, allocator: std.mem.Allocator, tag: ElementTag) ?*Component {
|
||||
switch (self.*) {
|
||||
.element => |*elem| {
|
||||
if (elem.tag == tag) {
|
||||
return self;
|
||||
}
|
||||
// Search in children - need to make children mutable first if they're const
|
||||
if (elem.children) |children| {
|
||||
// Allocate mutable copy of children for searching
|
||||
const mutable_children = allocator.alloc(Component, children.len) catch return null;
|
||||
@memcpy(mutable_children, children);
|
||||
elem.children = mutable_children;
|
||||
|
||||
for (0..mutable_children.len) |i| {
|
||||
var child_mut = &mutable_children[i];
|
||||
if (child_mut.getElementByName(allocator, tag)) |found| {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
.component_fn => |*func| {
|
||||
// Resolve the component function and replace self with the result
|
||||
const resolved = func.call() catch return null;
|
||||
self.* = resolved;
|
||||
// Now search the resolved component
|
||||
return self.getElementByName(allocator, tag);
|
||||
},
|
||||
.none, .text, .component_csr, .signal_text => return null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a child component to an element
|
||||
/// Only works if this Component is an element variant
|
||||
/// Note: Allocates a new array since children may be const
|
||||
pub fn appendChild(self: *Component, allocator: std.mem.Allocator, child: Component) !void {
|
||||
switch (self.*) {
|
||||
.element => |*elem| {
|
||||
if (elem.children) |existing_children| {
|
||||
// Allocate new array and copy existing children + new child
|
||||
const new_children = try allocator.alloc(Component, existing_children.len + 1);
|
||||
@memcpy(new_children[0..existing_children.len], existing_children);
|
||||
new_children[existing_children.len] = child;
|
||||
elem.children = new_children;
|
||||
} else {
|
||||
// Allocate new array
|
||||
const new_children = try allocator.alloc(Component, 1);
|
||||
new_children[0] = child;
|
||||
elem.children = new_children;
|
||||
}
|
||||
},
|
||||
else => return error.NotAnElement,
|
||||
}
|
||||
}
|
||||
|
||||
pub const SerializeOptions = struct {
|
||||
only_components: bool = true,
|
||||
include_props: bool = true,
|
||||
include_attributes: bool = true,
|
||||
};
|
||||
|
||||
pub fn format(
|
||||
self: *const Component,
|
||||
w: *std.Io.Writer,
|
||||
) error{WriteFailed}!void {
|
||||
self.formatWithOptions(w, .{}) catch return error.WriteFailed;
|
||||
}
|
||||
|
||||
pub fn formatWithOptions(
|
||||
self: *const Component,
|
||||
w: *std.Io.Writer,
|
||||
options: SerializeOptions,
|
||||
) anyerror!void {
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer arena.deinit();
|
||||
const allocator = arena.allocator();
|
||||
|
||||
var serializable = try devtool.ComponentSerializable.init(allocator, self.*, options);
|
||||
try serializable.serialize(w);
|
||||
}
|
||||
};
|
||||
|
||||
pub const Element = struct {
|
||||
pub const Attribute = struct {
|
||||
name: []const u8,
|
||||
value: ?[]const u8 = null,
|
||||
handler: ?zx.EventHandler = null,
|
||||
};
|
||||
|
||||
tag: ElementTag,
|
||||
children: ?[]const Component = null,
|
||||
attributes: ?[]const Element.Attribute = null,
|
||||
|
||||
escaping: ?BuiltinAttribute.Escaping = .html,
|
||||
rendering: ?BuiltinAttribute.Rendering = .server,
|
||||
async: ?BuiltinAttribute.Async = .sync,
|
||||
fallback: ?*const Component = null,
|
||||
};
|
||||
@ -1,15 +1,19 @@
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const zx = @import("root.zig");
|
||||
const Request = @import("runtime/core/Request.zig");
|
||||
const Response = @import("runtime/core/Response.zig");
|
||||
const pltfm = @import("platform.zig");
|
||||
const platform = pltfm.platform;
|
||||
const client = @import("runtime/client/window.zig");
|
||||
|
||||
const Component = zx.Component;
|
||||
const Signal = zx.Signal;
|
||||
const SignalInstance = zx.SignalInstance;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const client_allocator = if (builtin.os.tag == .freestanding) std.heap.wasm_allocator else std.heap.page_allocator;
|
||||
const platform = zx.platform;
|
||||
const client_allocator = zx.client_allocator;
|
||||
|
||||
/// Context passed to proxy middleware functions.
|
||||
/// Use `state.set()` to pass typed data to downstream route/page handlers.
|
||||
@ -110,3 +114,34 @@ pub const ActionContext = struct {
|
||||
return .{ .action_ref = action_ref };
|
||||
}
|
||||
};
|
||||
|
||||
/// Builder returned by ctx.Signal(T) - call .init(initial) to create the signal.
|
||||
fn SignalBuilder(comptime T: type) type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
_id: u16,
|
||||
|
||||
/// Initialize the signal with an initial value.
|
||||
/// Usage: `const count = ctx.Signal(i32).init(0);`
|
||||
pub fn init(self: Self, initial: T) SignalInstance(T) {
|
||||
return Signal(T).create(self._id, initial);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub fn ComponentCtx(comptime PropsType: type) type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
props: PropsType,
|
||||
allocator: Allocator,
|
||||
children: ?Component = null,
|
||||
/// Instance ID - automatically injected by Client.zig at runtime
|
||||
_id: u16 = 0,
|
||||
|
||||
/// Get a signal builder for this component instance.
|
||||
/// Usage: `const count = ctx.Signal(i32).init(ctx.props.initial);`
|
||||
pub fn Signal(self: Self, comptime T: type) SignalBuilder(T) {
|
||||
return .{ ._id = self._id };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
const std = @import("std");
|
||||
|
||||
const pltfm = @import("platform.zig");
|
||||
const platform = pltfm.platform;
|
||||
const Client = @import("runtime/client/Client.zig");
|
||||
|
||||
pub const BuiltinAttribute = @import("attributes.zig").builtin;
|
||||
|
||||
pub const PageMethod = enum {
|
||||
@ -62,3 +67,10 @@ pub const ProxyOptions = struct {
|
||||
/// Whether to continue to the next handler if proxy doesn't handle the request
|
||||
pass_through: bool = true,
|
||||
};
|
||||
|
||||
/// Default std_options for zx apps.
|
||||
/// Re-export this in your main.zig:
|
||||
/// ```zig
|
||||
/// pub const std_options = zx.std_options;
|
||||
/// ```
|
||||
pub const std_options: std.Options = .{ .logFn = if (platform == .browser) Client.logFn else std.log.defaultLog };
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const std = @import("std");
|
||||
|
||||
const pltfm = @import("platform.zig");
|
||||
const hydration = @import("runtime/client/hydration.zig");
|
||||
const platform = pltfm.platform;
|
||||
|
||||
/// Coerce props to the target struct type, handling defaults
|
||||
@ -296,3 +297,8 @@ pub fn propsSerializer(comptime Props: type, allocator: std.mem.Allocator, props
|
||||
}.write,
|
||||
};
|
||||
}
|
||||
|
||||
pub const prop = struct {
|
||||
pub const serialize = serializePositional;
|
||||
pub const parse = hydration.parseProps;
|
||||
};
|
||||
|
||||
1423
src/root.zig
1423
src/root.zig
File diff suppressed because it is too large
Load Diff
@ -1149,7 +1149,7 @@ pub fn Handler(comptime AppCtxType: type) type {
|
||||
};
|
||||
|
||||
if (async_components.len > 0) {
|
||||
res.chunk(Component.streaming_bootstrap_script) catch |err| {
|
||||
res.chunk(rndr.streaming_bootstrap_script) catch |err| {
|
||||
std.debug.print("Error sending bootstrap script: {}\n", .{err});
|
||||
return err;
|
||||
};
|
||||
@ -1171,7 +1171,7 @@ pub fn Handler(comptime AppCtxType: type) type {
|
||||
var remaining = std.atomic.Value(usize).init(async_components.len);
|
||||
|
||||
const TaskContext = struct {
|
||||
async_comp: Component.AsyncComponent,
|
||||
async_comp: rndr.AsyncComponent,
|
||||
result: *AsyncResult,
|
||||
remaining: *std.atomic.Value(usize),
|
||||
|
||||
@ -1488,6 +1488,7 @@ const zx_options = @import("zx_options");
|
||||
const zx = @import("../../root.zig");
|
||||
const httpz_adapter = @import("adapter.zig");
|
||||
const pubsub = @import("pubsub.zig");
|
||||
const rndr = @import("render.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Component = zx.Component;
|
||||
|
||||
319
src/runtime/server/render.zig
Normal file
319
src/runtime/server/render.zig
Normal file
@ -0,0 +1,319 @@
|
||||
const std = @import("std");
|
||||
const zx = @import("../../root.zig");
|
||||
|
||||
pub const streaming_bootstrap_script =
|
||||
\\<script>window.$ZX=function(id,html){var t=document.getElementById('__ZX_S-'+id);if(t){var d=document.createElement('div');d.innerHTML=html;while(d.firstChild)t.parentNode.insertBefore(d.firstChild,t);t.remove();}}</script>
|
||||
;
|
||||
|
||||
/// Async component collected during streaming
|
||||
pub const AsyncComponent = struct {
|
||||
id: u32,
|
||||
component: zx.Component,
|
||||
|
||||
pub fn renderScript(self: AsyncComponent, allocator: std.mem.Allocator) ![]const u8 {
|
||||
var aw = std.io.Writer.Allocating.init(allocator);
|
||||
errdefer aw.deinit();
|
||||
|
||||
try self.component.render(&aw.writer);
|
||||
const html = aw.written();
|
||||
|
||||
// Build minimal script: <script>$ZX(id,`content`)</script>
|
||||
var script_writer = std.io.Writer.Allocating.init(allocator);
|
||||
errdefer script_writer.deinit();
|
||||
|
||||
try script_writer.writer.print("<script>$ZX({d},`", .{self.id});
|
||||
|
||||
// Escape backticks, backslashes, and $ in HTML for template literal
|
||||
for (html) |c| {
|
||||
switch (c) {
|
||||
'`' => try script_writer.writer.writeAll("\\`"),
|
||||
'\\' => try script_writer.writer.writeAll("\\\\"),
|
||||
'$' => try script_writer.writer.writeAll("\\$"),
|
||||
else => try script_writer.writer.writeByte(c),
|
||||
}
|
||||
}
|
||||
|
||||
try script_writer.writer.writeAll("`)</script>");
|
||||
|
||||
return script_writer.written();
|
||||
}
|
||||
};
|
||||
|
||||
/// Stream method that renders HTML while collecting async components
|
||||
/// Writes placeholders for @async={.stream} components and returns them for parallel rendering
|
||||
pub fn stream(self: zx.Component, allocator: std.mem.Allocator, writer: *std.Io.Writer) ![]AsyncComponent {
|
||||
var async_components = std.array_list.Managed(AsyncComponent).init(allocator);
|
||||
errdefer async_components.deinit();
|
||||
|
||||
var counter: u32 = 0;
|
||||
try renderInner(self, writer, .{
|
||||
.escaping = .html,
|
||||
.rendering = .server,
|
||||
.async_components = &async_components,
|
||||
.async_counter = &counter,
|
||||
});
|
||||
return async_components.toOwnedSlice();
|
||||
}
|
||||
|
||||
pub const RenderInnerOptions = struct {
|
||||
escaping: ?zx.BuiltinAttribute.Escaping = .html,
|
||||
rendering: ?zx.BuiltinAttribute.Rendering = .server,
|
||||
async_components: ?*std.array_list.Managed(AsyncComponent) = null,
|
||||
async_counter: ?*u32 = null,
|
||||
};
|
||||
|
||||
pub fn render(self: zx.Component, writer: *std.Io.Writer) !void {
|
||||
try renderInner(self, writer, .{ .escaping = .html, .rendering = .server });
|
||||
}
|
||||
|
||||
pub fn renderInner(self: zx.Component, writer: *std.Io.Writer, options: RenderInnerOptions) !void {
|
||||
switch (self) {
|
||||
.none => {
|
||||
// Render nothing
|
||||
},
|
||||
.text => |text| {
|
||||
if (options.escaping == .none) {
|
||||
try unescapeHtmlToWriter(writer, text);
|
||||
} else {
|
||||
try writer.print("{s}", .{text});
|
||||
}
|
||||
},
|
||||
.component_fn => |func| {
|
||||
// Check for component-level caching
|
||||
if (func.caching) |caching| {
|
||||
if (caching.seconds > 0) {
|
||||
// Generate cache key from function pointer + props pointer + optional custom key
|
||||
var key_buf: [128]u8 = undefined;
|
||||
const generated_key = if (caching.key) |custom_key|
|
||||
std.fmt.bufPrint(&key_buf, "cmp:{s}:{x}:{x}", .{
|
||||
custom_key,
|
||||
@intFromPtr(func.callFn),
|
||||
@intFromPtr(func.propsPtr),
|
||||
}) catch null
|
||||
else
|
||||
std.fmt.bufPrint(&key_buf, "cmp:{x}:{x}", .{
|
||||
@intFromPtr(func.callFn),
|
||||
@intFromPtr(func.propsPtr),
|
||||
}) catch null;
|
||||
|
||||
if (generated_key) |key| {
|
||||
// Try to get from cache
|
||||
if (zx.cache.get(key)) |cached_html| {
|
||||
try writer.writeAll(cached_html);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render to buffer for caching
|
||||
var buf_writer = std.Io.Writer.Allocating.init(func.allocator);
|
||||
const component = try func.call();
|
||||
try renderInner(component, &buf_writer.writer, options);
|
||||
|
||||
const rendered = buf_writer.written();
|
||||
zx.cache.put(key, rendered, caching.seconds);
|
||||
|
||||
// Write to actual output
|
||||
try writer.writeAll(rendered);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No caching or cache miss - render directly
|
||||
const component = try func.call();
|
||||
try renderInner(component, writer, options);
|
||||
},
|
||||
.component_csr => |component_csr| {
|
||||
// Start comment marker format: <!--$id {"prop":"value"}--> (JSON)
|
||||
// Both React and Zig components use JSON format
|
||||
if (component_csr.is_react) {
|
||||
// React component: use JSON format
|
||||
if (component_csr.writeProps) |writeProps| {
|
||||
if (component_csr.props_ptr) |props_ptr| {
|
||||
try writer.print("<!--${s} {s} ", .{ component_csr.id, component_csr.name });
|
||||
try writeProps(writer, props_ptr);
|
||||
try writer.print("-->", .{});
|
||||
} else {
|
||||
try writer.print("<!--${s} {s}-->", .{ component_csr.id, component_csr.name });
|
||||
}
|
||||
} else {
|
||||
try writer.print("<!--${s} {s}-->", .{ component_csr.id, component_csr.name });
|
||||
}
|
||||
} else {
|
||||
// Zig component: use JSON format (same as React)
|
||||
if (component_csr.writeProps) |writeProps| {
|
||||
if (component_csr.props_ptr) |props_ptr| {
|
||||
try writer.print("<!--${s} ", .{component_csr.id});
|
||||
try writeProps(writer, props_ptr);
|
||||
try writer.print("-->", .{});
|
||||
} else {
|
||||
try writer.print("<!--${s}-->", .{component_csr.id});
|
||||
}
|
||||
} else {
|
||||
// No props - just marker
|
||||
try writer.print("<!--${s}-->", .{component_csr.id});
|
||||
}
|
||||
}
|
||||
|
||||
// Render SSR content
|
||||
if (component_csr.children) |children| {
|
||||
try renderInner(children.*, writer, options);
|
||||
}
|
||||
|
||||
// End comment marker: <!--/$id-->
|
||||
try writer.print("<!--/${s}-->", .{component_csr.id});
|
||||
},
|
||||
.signal_text => |sig| {
|
||||
if (options.escaping == .none) {
|
||||
try unescapeHtmlToWriter(writer, sig.current_text);
|
||||
} else {
|
||||
try writer.print("{s}", .{sig.current_text});
|
||||
}
|
||||
},
|
||||
.element => |elem| {
|
||||
// Check if this element is async and we're collecting async components
|
||||
if (options.async_components != null and elem.async == .stream) {
|
||||
const async_id = options.async_counter.?.*;
|
||||
options.async_counter.?.* += 1;
|
||||
|
||||
// Write placeholder div with fallback content
|
||||
try writer.print("<div id=\"__ZX_S-{d}\">", .{async_id});
|
||||
|
||||
// Render fallback content if provided
|
||||
if (elem.fallback) |fallback| {
|
||||
try renderInner(fallback.*, writer, .{
|
||||
.escaping = options.escaping,
|
||||
.rendering = options.rendering,
|
||||
});
|
||||
}
|
||||
|
||||
try writer.writeAll("</div>");
|
||||
|
||||
// Collect for async rendering
|
||||
try options.async_components.?.append(.{
|
||||
.id = async_id,
|
||||
.component = self,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// <><div>...</div></> => <div>...</div>
|
||||
if (elem.tag == .fragment) {
|
||||
if (elem.children) |children| {
|
||||
for (children) |child| {
|
||||
try renderInner(child, writer, options);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, render normally
|
||||
// Opening tag
|
||||
try writer.print("<{s}", .{@tagName(elem.tag)});
|
||||
|
||||
const is_self_closing = elem.tag.isSelf();
|
||||
const is_no_closing = elem.tag.isVoid();
|
||||
|
||||
// Handle attributes
|
||||
if (elem.attributes) |attributes| {
|
||||
for (attributes) |attribute| {
|
||||
if (attribute.handler) |handler| {
|
||||
// try writer.print(" {s}", .{attribute.name});
|
||||
// try handler(.{});
|
||||
_ = handler;
|
||||
} else {
|
||||
try writer.print(" {s}", .{attribute.name});
|
||||
}
|
||||
if (attribute.value) |value| {
|
||||
try writer.writeAll("=\"");
|
||||
try escapeHtmlAttrVal(writer, value);
|
||||
try writer.writeAll("\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Closing bracket
|
||||
if (!is_self_closing or is_no_closing) {
|
||||
try writer.print(">", .{});
|
||||
} else {
|
||||
try writer.print(" />", .{});
|
||||
}
|
||||
|
||||
// Render children (recursively collect slots if needed)
|
||||
if (elem.children) |children| {
|
||||
// Use element's escaping setting if set, otherwise inherit from parent
|
||||
const child_options = RenderInnerOptions{
|
||||
.escaping = elem.escaping orelse options.escaping,
|
||||
.rendering = elem.rendering orelse options.rendering,
|
||||
.async_components = options.async_components,
|
||||
.async_counter = options.async_counter,
|
||||
};
|
||||
for (children) |child| {
|
||||
try renderInner(child, writer, child_options);
|
||||
}
|
||||
}
|
||||
|
||||
// Closing tag
|
||||
if (!is_self_closing and !is_no_closing) {
|
||||
try writer.print("</{s}>", .{@tagName(elem.tag)});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes: & < > " '
|
||||
pub fn escapeHtmlAttrVal(writer: *std.Io.Writer, value: []const u8) !void {
|
||||
for (value) |char| {
|
||||
switch (char) {
|
||||
'&' => try writer.writeAll("&"),
|
||||
'<' => try writer.writeAll("<"),
|
||||
'>' => try writer.writeAll(">"),
|
||||
'"' => try writer.writeAll("""),
|
||||
'\'' => try writer.writeAll("'"),
|
||||
else => try writer.writeByte(char),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes: & < >
|
||||
pub fn escapHtmlTextNode(writer: *std.Io.Writer, value: []const u8) !void {
|
||||
for (value) |char| {
|
||||
switch (char) {
|
||||
'&' => try writer.writeAll("&"),
|
||||
'<' => try writer.writeAll("<"),
|
||||
'>' => try writer.writeAll(">"),
|
||||
else => try writer.writeByte(char),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unescapeHtmlToWriter(writer: *std.Io.Writer, value: []const u8) !void {
|
||||
var i: usize = 0;
|
||||
while (i < value.len) {
|
||||
if (value[i] == '&') {
|
||||
// Check for HTML entities
|
||||
if (i + 4 <= value.len and std.mem.eql(u8, value[i .. i + 4], "<")) {
|
||||
try writer.writeByte('<');
|
||||
i += 4;
|
||||
} else if (i + 4 <= value.len and std.mem.eql(u8, value[i .. i + 4], ">")) {
|
||||
try writer.writeByte('>');
|
||||
i += 4;
|
||||
} else if (i + 5 <= value.len and std.mem.eql(u8, value[i .. i + 5], "&")) {
|
||||
try writer.writeByte('&');
|
||||
i += 5;
|
||||
} else if (i + 6 <= value.len and std.mem.eql(u8, value[i .. i + 6], """)) {
|
||||
try writer.writeByte('"');
|
||||
i += 6;
|
||||
} else if (i + 6 <= value.len and std.mem.eql(u8, value[i .. i + 6], "'")) {
|
||||
try writer.writeByte('\'');
|
||||
i += 6;
|
||||
} else {
|
||||
// Not a recognized entity, write the ampersand as-is
|
||||
try writer.writeByte(value[i]);
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
try writer.writeByte(value[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
714
src/zx.zig
Normal file
714
src/zx.zig
Normal file
@ -0,0 +1,714 @@
|
||||
const std = @import("std");
|
||||
|
||||
const zx = @import("root.zig");
|
||||
|
||||
const ElementTag = zx.ElementTag;
|
||||
const Element = zx.Element;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const BuiltinAttribute = zx.BuiltinAttribute;
|
||||
const prp = @import("props.zig");
|
||||
const devtool = zx.devtool;
|
||||
const cache = zx.cache;
|
||||
const Component = zx.Component;
|
||||
const ElementAttribute = zx.Element.Attribute;
|
||||
const Client = zx.Client;
|
||||
|
||||
const platform = zx.platform;
|
||||
const escapHtmlTextNode = @import("runtime/server/render.zig").escapHtmlTextNode;
|
||||
|
||||
pub const ClientComponentOptions = struct {
|
||||
name: []const u8,
|
||||
path: []const u8,
|
||||
id: []const u8,
|
||||
};
|
||||
|
||||
pub const ComponentClientOptions = struct {
|
||||
name: []const u8,
|
||||
id: []const u8,
|
||||
};
|
||||
|
||||
const ZxOptions = struct {
|
||||
children: ?[]const Component = null,
|
||||
attributes: ?[]const Element.Attribute = null,
|
||||
allocator: ?std.mem.Allocator = null,
|
||||
escaping: ?BuiltinAttribute.Escaping = .html,
|
||||
rendering: ?BuiltinAttribute.Rendering = .server,
|
||||
async: ?BuiltinAttribute.Async = .sync,
|
||||
fallback: ?*const Component = null,
|
||||
caching: ?BuiltinAttribute.Caching = null,
|
||||
client: ?ComponentClientOptions = null,
|
||||
/// Component name used for devtools / debugging.
|
||||
/// Pass `null` (or omit) in release builds to reduce binary size.
|
||||
name: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// Initialize a ZxContext without an allocator
|
||||
/// The allocator must be provided via @allocator attribute on the parent element
|
||||
pub fn init() ZxContext {
|
||||
return .{ .allocator = std.heap.page_allocator };
|
||||
}
|
||||
|
||||
/// Initialize a ZxContext with an allocator (for backward compatibility with direct API usage)
|
||||
pub fn allocInit(allocator: std.mem.Allocator) ZxContext {
|
||||
return .{ .allocator = allocator };
|
||||
}
|
||||
|
||||
pub fn x(tag: ElementTag, options: ZxOptions) Component {
|
||||
return .{ .element = .{
|
||||
.tag = tag,
|
||||
.children = options.children,
|
||||
.attributes = options.attributes,
|
||||
} };
|
||||
}
|
||||
|
||||
/// Create a lazy component from a function
|
||||
/// The function will be invoked during rendering, allowing for dynamic slot handling
|
||||
/// Supports functions with 0 params (), 1 param (allocator), or 2 params (allocator, props)
|
||||
pub fn lazy(allocator: Allocator, comptime func: anytype, props: anytype) Component {
|
||||
return .{ .component_fn = Component.ComponentFn.init(func, allocator, props) };
|
||||
}
|
||||
|
||||
/// Context for creating components with allocator support
|
||||
pub const ZxContext = struct {
|
||||
allocator: ?std.mem.Allocator = null,
|
||||
|
||||
pub fn getAlloc(self: *ZxContext) std.mem.Allocator {
|
||||
return self.allocator orelse @panic("Allocator not set. Please provide @allocator attribute to the parent element.");
|
||||
}
|
||||
|
||||
fn escapeHtml(self: *ZxContext, text: []const u8) []const u8 {
|
||||
// On browser, DOM APIs (textContent) handle escaping automatically
|
||||
// We only need to escape when generating HTML strings on the server
|
||||
// TODO: we would want to move the escaping logic at the time of rendering the element, and simply not use escapeHtml for client side rendering
|
||||
if (platform == .browser) return text;
|
||||
|
||||
const allocator = self.getAlloc();
|
||||
// Use a buffer writer to leverage the shared escaping logic
|
||||
// For text content, we only escape & < > (not quotes)
|
||||
var aw = std.io.Writer.Allocating.init(allocator);
|
||||
escapHtmlTextNode(&aw.writer, text) catch @panic("OOM");
|
||||
return aw.written();
|
||||
}
|
||||
|
||||
pub fn ele(self: *ZxContext, tag: ElementTag, options: ZxOptions) Component {
|
||||
// Set allocator from @allocator option if provided
|
||||
if (options.allocator) |allocator| {
|
||||
self.allocator = allocator;
|
||||
}
|
||||
|
||||
const allocator = self.getAlloc();
|
||||
|
||||
// Allocate and copy children if provided
|
||||
const children_copy = if (options.children) |children| blk: {
|
||||
const copy = allocator.alloc(Component, children.len) catch @panic("OOM");
|
||||
@memcpy(copy, children);
|
||||
break :blk copy;
|
||||
} else null;
|
||||
|
||||
// Allocate and copy attributes if provided
|
||||
const attributes_copy = if (options.attributes) |attributes| blk: {
|
||||
const copy = allocator.alloc(Element.Attribute, attributes.len) catch @panic("OOM");
|
||||
@memcpy(copy, attributes);
|
||||
break :blk copy;
|
||||
} else null;
|
||||
|
||||
return .{ .element = .{
|
||||
.tag = tag,
|
||||
.children = children_copy,
|
||||
.attributes = attributes_copy,
|
||||
.escaping = options.escaping,
|
||||
.rendering = options.rendering,
|
||||
.async = options.async,
|
||||
.fallback = options.fallback,
|
||||
} };
|
||||
}
|
||||
|
||||
pub fn txt(self: *ZxContext, text: []const u8) Component {
|
||||
const escaped = self.escapeHtml(text);
|
||||
return .{ .text = escaped };
|
||||
}
|
||||
|
||||
pub fn expr(self: *ZxContext, val: anytype) Component {
|
||||
const T = @TypeOf(val);
|
||||
|
||||
if (T == Component) return val;
|
||||
|
||||
// Check if it's a Signal pointer - enable fine-grained reactivity
|
||||
if (comptime isSignalPointer(T)) {
|
||||
const allocator = self.getAlloc();
|
||||
// Ensure the signal has a runtime ID for DOM binding
|
||||
val.ensureId();
|
||||
// Format the current value as text
|
||||
const ValueType = Client.reactivity.SignalValueType(T);
|
||||
const current_value = val.get();
|
||||
const text = formatSignalValue(ValueType, current_value, allocator);
|
||||
return .{ .signal_text = .{
|
||||
.signal_id = val.id,
|
||||
.current_text = text,
|
||||
} };
|
||||
}
|
||||
|
||||
// Check if it's a Computed pointer - enable fine-grained reactivity like Signal
|
||||
if (comptime isComputedPointer(T)) {
|
||||
const allocator = self.getAlloc();
|
||||
// Ensure the computed has a runtime ID and subscribes to source
|
||||
val.ensureId();
|
||||
@constCast(val).subscribe();
|
||||
// Format the current value as text
|
||||
const ValueType = ComputedValueType(T);
|
||||
const current_value = val.get();
|
||||
const text = formatSignalValue(ValueType, current_value, allocator);
|
||||
return .{ .signal_text = .{
|
||||
.signal_id = val.id,
|
||||
.current_text = text,
|
||||
} };
|
||||
}
|
||||
|
||||
// Check if it's a Signal VALUE (not pointer) - compile error with helpful message
|
||||
if (comptime isSignalValue(T)) {
|
||||
@compileError(
|
||||
\\Signal passed by value - reactivity won't work.
|
||||
\\Use `{&signal}` instead of `{signal}` to enable reactive updates.
|
||||
\\
|
||||
\\Example: <h5>{&count}</h5> instead of <h5>{count}</h5>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's a Computed VALUE (not pointer) - compile error with helpful message
|
||||
if (comptime isComputedValue(T)) {
|
||||
@compileError(
|
||||
\\Computed passed by value - reactivity won't work.
|
||||
\\Use `{&computed}` instead of `{computed}` to enable reactive updates.
|
||||
\\
|
||||
\\Example: <h5>{&doubled}</h5> instead of <h5>{doubled}</h5>
|
||||
);
|
||||
}
|
||||
|
||||
const Cmp = switch (@typeInfo(T)) {
|
||||
.comptime_int, .comptime_float, .float => self.fmt("{d}", .{val}),
|
||||
.int => if (T == u8 and std.ascii.isPrint(val))
|
||||
self.fmt("{c}", .{val})
|
||||
else
|
||||
self.fmt("{d}", .{val}),
|
||||
.bool => self.fmt("{s}", .{if (val) "true" else "false"}),
|
||||
.null => self.ele(.fragment, .{}), // Render nothing for null
|
||||
.optional => if (val) |inner| self.expr(inner) else self.ele(.fragment, .{}),
|
||||
.@"enum", .enum_literal => self.txt(@tagName(val)),
|
||||
.pointer => |ptr_info| switch (ptr_info.size) {
|
||||
.one => switch (@typeInfo(ptr_info.child)) {
|
||||
.array => {
|
||||
// Coerce `*[N]T` to `[]const T`.
|
||||
const Slice = []const std.meta.Elem(ptr_info.child);
|
||||
return self.expr(@as(Slice, val));
|
||||
},
|
||||
else => {
|
||||
return self.expr(val.*);
|
||||
},
|
||||
},
|
||||
.many, .slice => {
|
||||
if (ptr_info.size == .many and ptr_info.sentinel() == null)
|
||||
@compileError("unable to stringify type '" ++ @typeName(T) ++ "' without sentinel");
|
||||
const slice = if (ptr_info.size == .many) std.mem.span(val) else val;
|
||||
|
||||
if (ptr_info.child == u8) {
|
||||
// This is a []const u8, or some similar Zig string.
|
||||
if (std.unicode.utf8ValidateSlice(slice)) {
|
||||
return txt(self, slice);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle slices of Components
|
||||
if (ptr_info.child == Component) {
|
||||
return .{ .element = .{
|
||||
.tag = .fragment,
|
||||
.children = val,
|
||||
} };
|
||||
}
|
||||
|
||||
return self.txt(slice);
|
||||
},
|
||||
|
||||
else => @compileError("Unable to render type '" ++ @typeName(T) ++ "', supported types are: int, float, bool, string, enum, optional"),
|
||||
},
|
||||
.@"struct" => |struct_info| {
|
||||
var aw = std.io.Writer.Allocating.init(self.getAlloc());
|
||||
defer aw.deinit();
|
||||
|
||||
// aw.writer.print("{s} ", .{@tagName(struct_info)}) catch @panic("OOM");
|
||||
_ = struct_info;
|
||||
std.zon.stringify.serializeMaxDepth(val, .{ .whitespace = true }, &aw.writer, 100) catch |err| {
|
||||
return self.fmt("{s}", .{@errorName(err)});
|
||||
};
|
||||
|
||||
return self.txt(aw.written());
|
||||
},
|
||||
.array => |arr_info| {
|
||||
// Handle arrays of Components
|
||||
if (arr_info.child == Component) {
|
||||
return .{ .element = .{
|
||||
.tag = .fragment,
|
||||
.children = &val,
|
||||
} };
|
||||
}
|
||||
@compileError("Unable to render array of type '" ++ @typeName(arr_info.child) ++ "', only Component arrays are supported");
|
||||
},
|
||||
else => @compileError("Unable to render type '" ++ @typeName(T) ++ "', supported types are: int, float, bool, string, enum, optional"),
|
||||
};
|
||||
|
||||
return Cmp;
|
||||
}
|
||||
|
||||
pub fn fmt(self: *ZxContext, comptime format: []const u8, args: anytype) Component {
|
||||
const allocator = self.getAlloc();
|
||||
const text = std.fmt.allocPrint(allocator, format, args) catch @panic("OOM");
|
||||
return .{ .text = text };
|
||||
}
|
||||
|
||||
pub fn printf(self: *ZxContext, comptime format: []const u8, args: anytype) []const u8 {
|
||||
const allocator = self.getAlloc();
|
||||
const text = std.fmt.allocPrint(allocator, format, args) catch @panic("OOM");
|
||||
return text;
|
||||
}
|
||||
|
||||
/// Create an attribute with type-aware value handling
|
||||
/// Returns null for values that should omit the attribute (false booleans, null optionals)
|
||||
pub fn attr(self: *ZxContext, comptime name: []const u8, val: anytype) ?Element.Attribute {
|
||||
const T = @TypeOf(val);
|
||||
|
||||
return switch (@typeInfo(T)) {
|
||||
// Strings and function pointers
|
||||
.pointer => |ptr_info| blk: {
|
||||
if (ptr_info.size == .slice and ptr_info.child == u8) {
|
||||
break :blk .{ .name = name, .value = val };
|
||||
}
|
||||
if (ptr_info.size == .one) {
|
||||
if (@typeInfo(ptr_info.child) == .array) {
|
||||
const Slice = []const std.meta.Elem(ptr_info.child);
|
||||
return self.attr(name, @as(Slice, val));
|
||||
}
|
||||
// Function pointer - treat as event handler
|
||||
if (@typeInfo(ptr_info.child) == .@"fn") {
|
||||
break :blk .{ .name = name, .handler = val };
|
||||
}
|
||||
}
|
||||
@compileError("Unsupported pointer type for attribute: " ++ @typeName(T));
|
||||
},
|
||||
|
||||
// Integers - format to string
|
||||
.int, .comptime_int => .{
|
||||
.name = name,
|
||||
.value = self.printf("{d}", .{val}),
|
||||
},
|
||||
|
||||
// Floats - format with default precision
|
||||
.float, .comptime_float => .{
|
||||
.name = name,
|
||||
.value = self.printf("{d}", .{val}),
|
||||
},
|
||||
|
||||
// Booleans - presence-only attribute (true) or omit (false)
|
||||
.bool => if (val) .{ .name = name, .value = null } else null,
|
||||
|
||||
// Optionals - recurse if non-null, omit if null
|
||||
.optional => if (val) |inner| self.attr(name, inner) else null,
|
||||
|
||||
// Enums - convert tag name to string
|
||||
.@"enum", .enum_literal => .{
|
||||
.name = name,
|
||||
.value = @tagName(val),
|
||||
},
|
||||
|
||||
// Event handlers - store as function pointer
|
||||
.@"fn" => .{
|
||||
.name = name,
|
||||
.handler = val,
|
||||
},
|
||||
else => @compileError("Unsupported type for attribute value: " ++ @typeName(T)),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn attrf(self: *ZxContext, comptime name: []const u8, comptime format: []const u8, args: anytype) ?Element.Attribute {
|
||||
const allocator = self.getAlloc();
|
||||
const text = std.fmt.allocPrint(allocator, format, args) catch @panic("OOM");
|
||||
return self.attr(name, text);
|
||||
}
|
||||
|
||||
pub fn attrv(self: *ZxContext, val: anytype) []const u8 {
|
||||
const attrkv = self.attr("f", val);
|
||||
if (attrkv) |a| {
|
||||
return a.value orelse "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
pub fn propf(self: *ZxContext, comptime format: []const u8, args: anytype) []const u8 {
|
||||
const allocator = self.getAlloc();
|
||||
return std.fmt.allocPrint(allocator, format, args) catch @panic("OOM");
|
||||
}
|
||||
pub const propv = attrv;
|
||||
|
||||
/// Filter and collect non-null attributes into a slice
|
||||
pub fn attrs(self: *ZxContext, inputs: anytype) []const Element.Attribute {
|
||||
const allocator = self.getAlloc();
|
||||
const InputType = @TypeOf(inputs);
|
||||
const input_info = @typeInfo(InputType);
|
||||
|
||||
// Handle tuple/struct (comptime known)
|
||||
if (input_info == .@"struct" and input_info.@"struct".is_tuple) {
|
||||
// Count non-null attributes at runtime
|
||||
var count: usize = 0;
|
||||
inline for (inputs) |input| {
|
||||
if (@TypeOf(input) == ?Element.Attribute) {
|
||||
if (input != null) count += 1;
|
||||
} else {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (count == 0) return &.{};
|
||||
|
||||
const result = allocator.alloc(Element.Attribute, count) catch @panic("OOM");
|
||||
var idx: usize = 0;
|
||||
inline for (inputs) |input| {
|
||||
if (@TypeOf(input) == ?Element.Attribute) {
|
||||
if (input) |a| {
|
||||
result[idx] = a;
|
||||
idx += 1;
|
||||
}
|
||||
} else {
|
||||
result[idx] = input;
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@compileError("attrs() expects a tuple of attributes");
|
||||
}
|
||||
|
||||
/// Spread a struct's fields as attributes
|
||||
/// Takes a struct and returns a slice of attributes for each field
|
||||
pub fn attrSpr(self: *ZxContext, props: anytype) []const ?Element.Attribute {
|
||||
const allocator = self.getAlloc();
|
||||
const T = @TypeOf(props);
|
||||
const type_info = @typeInfo(T);
|
||||
|
||||
if (type_info != .@"struct") {
|
||||
@compileError("attrSpr() expects a struct, got " ++ @typeName(T));
|
||||
}
|
||||
|
||||
const fields = type_info.@"struct".fields;
|
||||
if (fields.len == 0) return &.{};
|
||||
|
||||
const result = allocator.alloc(?Element.Attribute, fields.len) catch @panic("OOM");
|
||||
|
||||
inline for (fields, 0..) |field, i| {
|
||||
const val = @field(props, field.name);
|
||||
result[i] = self.attr(field.name, val);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Merge two structs for component props spreading
|
||||
/// Later fields override earlier ones
|
||||
pub fn propsM(_: *ZxContext, base: anytype, overrides: anytype) prp.MergedPropsType(@TypeOf(base), @TypeOf(overrides)) {
|
||||
const BaseType = @TypeOf(base);
|
||||
const OverrideType = @TypeOf(overrides);
|
||||
const ResultType = prp.MergedPropsType(BaseType, OverrideType);
|
||||
|
||||
var result: ResultType = undefined;
|
||||
|
||||
// Copy all fields from base
|
||||
const base_info = @typeInfo(BaseType);
|
||||
if (base_info == .@"struct") {
|
||||
inline for (base_info.@"struct".fields) |field| {
|
||||
if (@hasField(ResultType, field.name)) {
|
||||
@field(result, field.name) = @field(base, field.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply overrides (these take precedence)
|
||||
const override_info = @typeInfo(OverrideType);
|
||||
if (override_info == .@"struct") {
|
||||
inline for (override_info.@"struct".fields) |field| {
|
||||
@field(result, field.name) = @field(overrides, field.name);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Merge multiple attribute sources (including spread results) into a single slice
|
||||
/// Accepts a tuple where each element can be:
|
||||
/// - ?Element.Attribute (single attribute from attr())
|
||||
/// - []const ?Element.Attribute (slice from attrSpr())
|
||||
/// Later attributes with the same name override earlier ones (like JSX)
|
||||
pub fn attrsM(self: *ZxContext, inputs: anytype) []const Element.Attribute {
|
||||
const allocator = self.getAlloc();
|
||||
const InputType = @TypeOf(inputs);
|
||||
const input_info = @typeInfo(InputType);
|
||||
|
||||
if (input_info != .@"struct" or !input_info.@"struct".is_tuple) {
|
||||
@compileError("attrsM() expects a tuple of attributes or attribute slices");
|
||||
}
|
||||
|
||||
// First pass: collect all attributes in order
|
||||
var count: usize = 0;
|
||||
inline for (inputs) |input| {
|
||||
const T = @TypeOf(input);
|
||||
if (T == ?Element.Attribute) {
|
||||
if (input != null) count += 1;
|
||||
} else if (T == []const ?Element.Attribute) {
|
||||
for (input) |maybe_attr| {
|
||||
if (maybe_attr != null) count += 1;
|
||||
}
|
||||
} else {
|
||||
@compileError("attrsM() element must be ?Element.Attribute or []const ?Element.Attribute, got " ++ @typeName(T));
|
||||
}
|
||||
}
|
||||
|
||||
if (count == 0) return &.{};
|
||||
|
||||
// Collect all attributes in order (later ones override earlier)
|
||||
const temp = allocator.alloc(Element.Attribute, count) catch @panic("OOM");
|
||||
var idx: usize = 0;
|
||||
|
||||
inline for (inputs) |input| {
|
||||
const T = @TypeOf(input);
|
||||
if (T == ?Element.Attribute) {
|
||||
if (input) |a| {
|
||||
temp[idx] = a;
|
||||
idx += 1;
|
||||
}
|
||||
} else if (T == []const ?Element.Attribute) {
|
||||
for (input) |maybe_attr| {
|
||||
if (maybe_attr) |a| {
|
||||
temp[idx] = a;
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate atrrs, keep last occurrence
|
||||
var unique_count: usize = 0;
|
||||
var i: usize = temp.len;
|
||||
while (i > 0) {
|
||||
i -= 1;
|
||||
const current = temp[i];
|
||||
var found_later = false;
|
||||
for (temp[i + 1 ..]) |later| {
|
||||
if (std.mem.eql(u8, current.name, later.name)) {
|
||||
found_later = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found_later) {
|
||||
unique_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result = allocator.alloc(Element.Attribute, unique_count) catch @panic("OOM");
|
||||
var result_idx: usize = 0;
|
||||
|
||||
for (temp, 0..) |current_attr, j| {
|
||||
var found_later = false;
|
||||
for (temp[j + 1 ..]) |later| {
|
||||
if (std.mem.eql(u8, current_attr.name, later.name)) {
|
||||
found_later = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found_later) {
|
||||
result[result_idx] = current_attr;
|
||||
result_idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
allocator.free(temp);
|
||||
return result;
|
||||
}
|
||||
|
||||
pub fn cmp(self: *ZxContext, comptime func: anytype, options: ZxOptions, props: anytype) Component {
|
||||
const allocator = self.getAlloc();
|
||||
|
||||
const FuncInfo = @typeInfo(@TypeOf(func));
|
||||
const param_count = FuncInfo.@"fn".params.len;
|
||||
const FirstPropType = FuncInfo.@"fn".params[0].type.?;
|
||||
const first_is_ctx_ptr = @typeInfo(FirstPropType) == .pointer and
|
||||
@hasField(@typeInfo(FirstPropType).pointer.child, "allocator") and
|
||||
@hasField(@typeInfo(FirstPropType).pointer.child, "children");
|
||||
|
||||
const name = options.name orelse "";
|
||||
// Context-based component or function with props parameter
|
||||
var comp_fn = if (first_is_ctx_ptr or param_count == 2) blk: {
|
||||
const PropsType = if (first_is_ctx_ptr) @TypeOf(props) else FuncInfo.@"fn".params[1].type.?;
|
||||
const coerced_props = prp.coerceProps(PropsType, props);
|
||||
break :blk Component.ComponentFn.init(func, name, allocator, coerced_props);
|
||||
} else blk: {
|
||||
break :blk Component.ComponentFn.init(func, name, allocator, props);
|
||||
};
|
||||
|
||||
// Apply builtin attributes from options
|
||||
comp_fn.async_mode = options.async orelse .sync;
|
||||
comp_fn.fallback = options.fallback;
|
||||
comp_fn.caching = options.caching;
|
||||
|
||||
// If client option is set, return a client component (for @rendering={.client})
|
||||
// Render the component on the server for SSR, then hydrate on client
|
||||
if (options.client) |client_opts| {
|
||||
const name_copy = allocator.alloc(u8, client_opts.name.len) catch @panic("OOM");
|
||||
@memcpy(name_copy, client_opts.name);
|
||||
const id_copy = allocator.alloc(u8, client_opts.id.len) catch @panic("OOM");
|
||||
@memcpy(id_copy, client_opts.id);
|
||||
|
||||
// Call the component function to get SSR content
|
||||
const rendered = comp_fn.call() catch @panic("Component call failed");
|
||||
const children_ptr = allocator.create(Component) catch @panic("OOM");
|
||||
children_ptr.* = rendered;
|
||||
|
||||
// Get the full props type from the component function signature
|
||||
// and coerce partial props to include defaults - this ensures all fields are serialized
|
||||
const props_data = blk: {
|
||||
if (first_is_ctx_ptr) {
|
||||
const CtxType = @typeInfo(FirstPropType).pointer.child;
|
||||
if (@hasField(CtxType, "props")) {
|
||||
const FullPropsType = @FieldType(CtxType, "props");
|
||||
if (@typeInfo(FullPropsType) == .@"struct") {
|
||||
const full_props = prp.coerceProps(FullPropsType, props);
|
||||
break :blk prp.propsSerializer(FullPropsType, allocator, full_props);
|
||||
}
|
||||
}
|
||||
} else if (param_count == 2) {
|
||||
const FullPropsType = FuncInfo.@"fn".params[1].type.?;
|
||||
if (@typeInfo(FullPropsType) == .@"struct") {
|
||||
const full_props = prp.coerceProps(FullPropsType, props);
|
||||
break :blk prp.propsSerializer(FullPropsType, allocator, full_props);
|
||||
}
|
||||
}
|
||||
// Fallback: serialize the props as-is
|
||||
break :blk prp.propsSerializer(@TypeOf(props), allocator, props);
|
||||
};
|
||||
|
||||
return .{
|
||||
.component_csr = .{
|
||||
.name = name_copy,
|
||||
.id = id_copy,
|
||||
.props_ptr = props_data.ptr,
|
||||
.writeProps = props_data.writeFn,
|
||||
.children = children_ptr,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return .{ .component_fn = comp_fn };
|
||||
}
|
||||
|
||||
/// Allocates a Component and returns a pointer to it (used for @fallback)
|
||||
pub fn ptr(self: *ZxContext, component: Component) *const Component {
|
||||
const allocator = self.getAlloc();
|
||||
const allocated = allocator.create(Component) catch @panic("OOM");
|
||||
allocated.* = component;
|
||||
return allocated;
|
||||
}
|
||||
|
||||
/// Creates a React client-side rendered component.
|
||||
/// Uses JSON serialization for props to match React's expected format.
|
||||
pub fn client(self: *ZxContext, options: ClientComponentOptions, props: anytype) Component {
|
||||
const allocator = self.getAlloc();
|
||||
const Props = @TypeOf(props);
|
||||
|
||||
const name_copy = allocator.alloc(u8, options.name.len) catch @panic("OOM");
|
||||
@memcpy(name_copy, options.name);
|
||||
const id_copy = allocator.alloc(u8, options.id.len) catch @panic("OOM");
|
||||
@memcpy(id_copy, options.id);
|
||||
|
||||
// Use JSON serializer for React components
|
||||
const props_data = prp.propsSerializerJson(Props, allocator, props);
|
||||
|
||||
return .{
|
||||
.component_csr = .{
|
||||
.name = name_copy,
|
||||
.id = id_copy,
|
||||
.props_ptr = props_data.ptr,
|
||||
.writeProps = props_data.writeFn,
|
||||
.is_react = true,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Check at comptime if a type is a Signal struct (value, not pointer)
|
||||
fn isSignalValue(comptime T: type) bool {
|
||||
if (@typeInfo(T) != .@"struct") return false;
|
||||
|
||||
// Check for Signal's characteristic fields and declarations
|
||||
return @hasField(T, "id") and
|
||||
@hasField(T, "value") and
|
||||
@hasDecl(T, "get") and
|
||||
@hasDecl(T, "set") and
|
||||
@hasDecl(T, "notifyChange");
|
||||
}
|
||||
|
||||
/// Check at comptime if a type is a pointer to a Signal struct
|
||||
fn isSignalPointer(comptime T: type) bool {
|
||||
const type_info = @typeInfo(T);
|
||||
if (type_info != .pointer) return false;
|
||||
if (type_info.pointer.size != .one) return false;
|
||||
|
||||
return isSignalValue(type_info.pointer.child);
|
||||
}
|
||||
|
||||
/// Check at comptime if a type is a Computed struct (value, not pointer)
|
||||
fn isComputedValue(comptime T: type) bool {
|
||||
if (@typeInfo(T) != .@"struct") return false;
|
||||
|
||||
// Check for Computed's characteristic fields: source, compute, id, and subscribe
|
||||
return @hasField(T, "source") and
|
||||
@hasField(T, "compute") and
|
||||
@hasField(T, "id") and
|
||||
@hasDecl(T, "get") and
|
||||
@hasDecl(T, "subscribe");
|
||||
}
|
||||
|
||||
/// Check at comptime if a type is a pointer to a Computed struct
|
||||
fn isComputedPointer(comptime T: type) bool {
|
||||
const type_info = @typeInfo(T);
|
||||
if (type_info != .pointer) return false;
|
||||
if (type_info.pointer.size != .one) return false;
|
||||
|
||||
return isComputedValue(type_info.pointer.child);
|
||||
}
|
||||
|
||||
/// Get the value type from a Computed pointer type
|
||||
fn ComputedValueType(comptime T: type) type {
|
||||
const type_info = @typeInfo(T);
|
||||
if (type_info == .pointer) {
|
||||
const Child = type_info.pointer.child;
|
||||
if (@typeInfo(Child) == .@"struct" and @hasField(Child, "value")) {
|
||||
return @FieldType(Child, "value");
|
||||
}
|
||||
}
|
||||
@compileError("Expected a pointer to a Computed type");
|
||||
}
|
||||
|
||||
/// Format a Signal's value to a string for DOM text content
|
||||
fn formatSignalValue(comptime T: type, value: T, allocator: Allocator) []const u8 {
|
||||
return switch (@typeInfo(T)) {
|
||||
.int, .comptime_int => std.fmt.allocPrint(allocator, "{d}", .{value}) catch "?",
|
||||
.float, .comptime_float => std.fmt.allocPrint(allocator, "{d:.2}", .{value}) catch "?",
|
||||
.bool => if (value) "true" else "false",
|
||||
.pointer => |ptr_info| blk: {
|
||||
if (ptr_info.size == .slice and ptr_info.child == u8) {
|
||||
break :blk allocator.dupe(u8, value) catch "?";
|
||||
}
|
||||
break :blk std.fmt.allocPrint(allocator, "{any}", .{value}) catch "?";
|
||||
},
|
||||
.@"enum" => @tagName(value),
|
||||
.optional => if (value) |v| formatSignalValue(@TypeOf(v), v, allocator) else "",
|
||||
else => std.fmt.allocPrint(allocator, "{any}", .{value}) catch "?",
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user