diff options
| author | Nathan Reiner <nathan@nathanreiner.xyz> | 2026-05-28 16:30:26 +0200 |
|---|---|---|
| committer | Nathan Reiner <nathan@nathanreiner.xyz> | 2026-05-28 16:30:26 +0200 |
| commit | cd32a3ca0173676ed31486b5fa4dbd8c744cdec5 (patch) | |
| tree | a9d0888c7a0e425ef8006cc9aade5889b78c074c | |
| parent | e749e2ccf97e6e7a2d1c124c4e5bed688e4c06f2 (diff) | |
implement frontend framework
28 files changed, 392 insertions, 238 deletions
@@ -22,11 +22,17 @@ pub fn build(b: *std.Build) void { .target = target, }); - const frontend_mod = b.addModule("frontend", .{ - .root_source_file = b.path("src/frontend/root.zig"), + const html_mod = b.addModule("html", .{ + .root_source_file = b.path("src/html/root.zig"), .target = target, }); + const z_mod = b.addModule("z", .{ + .root_source_file = b.path("src/z/root.zig"), + .target = target, + }); + z_mod.addImport("html", html_mod); + const http_mod = b.addModule("http", .{ .root_source_file = b.path("src/http/root.zig"), .target = target, @@ -43,7 +49,8 @@ pub fn build(b: *std.Build) void { .imports = &.{ .{ .name = "db", .module = db_mod }, .{ .name = "api", .module = api_mod }, - .{ .name = "frontend", .module = frontend_mod }, + .{ .name = "z", .module = z_mod }, + .{ .name = "html", .module = html_mod }, .{ .name = "http", .module = http_mod }, } }), @@ -78,12 +85,19 @@ pub fn build(b: *std.Build) void { api_test_step.dependOn(&run_api_tests.step); - const frontend_tests = b.addTest(.{ - .root_module = frontend_mod, + const z_tests = b.addTest(.{ + .root_module = z_mod, + }); + const run_z_tests = b.addRunArtifact(z_tests); + const z_test_step = b.step("test-z", "Run z tests"); + z_test_step.dependOn(&run_z_tests.step); + + const html_tests = b.addTest(.{ + .root_module = html_mod, }); - const run_frontend_tests = b.addRunArtifact(frontend_tests); - const frontend_test_step = b.step("test-frontend", "Run frontend tests"); - frontend_test_step.dependOn(&run_frontend_tests.step); + const run_html_tests = b.addRunArtifact(html_tests); + const html_test_step = b.step("test-html", "Run html tests"); + html_test_step.dependOn(&run_html_tests.step); const http_tests = b.addTest(.{ @@ -103,7 +117,8 @@ pub fn build(b: *std.Build) void { const test_step = b.step("test", "Run all tests"); test_step.dependOn(&run_db_tests.step); test_step.dependOn(&run_api_tests.step); - test_step.dependOn(&run_frontend_tests.step); + test_step.dependOn(&run_z_tests.step); + test_step.dependOn(&run_html_tests.step); test_step.dependOn(&run_http_tests.step); test_step.dependOn(&run_exe_tests.step); @@ -130,4 +145,15 @@ pub fn build(b: *std.Build) void { .source_dir = exe.getEmittedDocs(), }); docs_step.dependOn(&docs_install.step); + + + const hot_cmd = b.addSystemCommand(&.{ + "sh", + "-c", + "clear; echo 'building in hot mode'; find src -type f | entr -r zig build run", + }); + + const hot_step = b.step("hot", "Run server in hot reload mode"); + hot_step.dependOn(&hot_cmd.step); + run_cmd.step.dependOn(b.getInstallStep()); } @@ -29,6 +29,7 @@ packages = [ pkgs.zig pkgs.sqlite + pkgs.entr ]; }; }; diff --git a/src/frontend/Component.zig b/src/frontend/Component.zig deleted file mode 100644 index d90ed3b..0000000 --- a/src/frontend/Component.zig +++ /dev/null @@ -1,30 +0,0 @@ -const std = @import("std"); - -const File = @import("File.zig"); - -const Self = @This(); - -name: []const u8, -parent_dir: []const u8, - -pub fn path(p: []const u8) Self { - const name = p[if (std.mem.lastIndexOfScalar( - u8, - std.mem.trim(u8, p, "/"), - '/', - )) |index| index + 1 else 0..]; - - return .{ - .name = name, - .parent_dir = p, - }; -} - -pub fn to_html_string(comptime self: Self) []const u8 { - const html: File.Html = .minify(self.parent_dir ++ "/index.html"); - const js: File.Script = .minify(self.parent_dir ++ "/index.js"); - const css: File.Style = .minify(self.parent_dir ++ "/index.css"); - return "<template id=\"--" ++ self.name ++ "\">" ++ css.to_html_string() ++ html.to_html_string() ++ js.to_html_string() ++ "</template>"; -} - -test {} diff --git a/src/frontend/Document.zig b/src/frontend/Document.zig deleted file mode 100644 index 0ba46b1..0000000 --- a/src/frontend/Document.zig +++ /dev/null @@ -1,46 +0,0 @@ -const std = @import("std"); - -const Component = @import("Component.zig"); -const Head = @import("Head.zig"); -const File = @import("File.zig"); - -const Self = @This(); - -components: []const Component, -head: Head, -styles: []const File.Style, -contents: []const File.Html, - -pub const empty: Self = .{ - .head = .empty, - .components = &.{}, - .styles = &.{}, - .contents = &.{}, -}; - -pub fn to_string(comptime self: Self) []const u8 { - comptime var content: []const u8 = "<!DOCTYPE html><html>"; - content = content ++ comptime self.head.to_html_string(self.components, self.styles); - content = content ++ "<body>"; - - inline for (self.contents) |c| { - content = content ++ comptime c.to_html_string(); - } - - content = content ++ "</body></html>"; - - return content; -} - -test { - const document: Self = comptime .{ - .head = .default("test"), - .components = &.{ - .path("./web/hello-world"), - }, - .styles = &.{}, - .contents = &.{.minify("./web/index.html")}, - }; - - std.debug.print("{s}", .{document.to_string()}); -} diff --git a/src/frontend/File.zig b/src/frontend/File.zig deleted file mode 100644 index fac6a03..0000000 --- a/src/frontend/File.zig +++ /dev/null @@ -1,41 +0,0 @@ -const Minifier = @import("Minifier.zig"); - -pub fn File(comptime tag: ?[]const u8, comptime minifier: Minifier) type { - const opening = if (tag) |t| "<" ++ t ++ ">" else ""; - const closing = if (tag) |t| "</" ++ t ++ ">" else ""; - - return struct { - file: []const u8, - do_minify: bool, - - pub fn path(comptime p: []const u8) @This() { - return .{ - .file = p, - .do_minify = false, - }; - } - - pub fn minify(comptime p: []const u8) @This() { - return .{ - .file = p, - .do_minify = true, - }; - } - - pub fn to_html_string(comptime self: @This()) []const u8 { - return opening ++ (if (self.do_minify) - minifier.minify(@embedFile(self.file)) - else - @embedFile(self.file)) ++ closing; - } - }; -} - -/// When using regex in Javascript the spaces -/// might get trimmed since /re/ is a awful syntax -/// I don't what to add a rule for it currently, -/// Since a single `/` is a operator but if there are two on one line -/// it is a regex. -pub const Script = File("script", .js); -pub const Style = File("style", .css); -pub const Html = File(null, .html); diff --git a/src/frontend/Head.zig b/src/frontend/Head.zig deleted file mode 100644 index 0eb090f..0000000 --- a/src/frontend/Head.zig +++ /dev/null @@ -1,58 +0,0 @@ -const File = @import("File.zig"); -const Component = @import("Component.zig"); - -const z_component_code: File.Script = .minify("z-component.js"); - -pub const Meta = struct { - name: []const u8, - content: []const u8, - - pub fn to_html_string(comptime self: @This()) []const u8 { - return "<meta name=\"" ++ self.name ++ "\" content=\"" ++ self.content ++ "\">"; - } -}; - -title: []const u8, -meta: []const Meta, - -pub const empty: @This() = .{ - .title = "", - .meta = &.{}, -}; - -pub fn default(comptime title: []const u8) @This() { - return .{ - .title = title, - .meta = &.{ - .{ - .name = "viewport", - .content = "width=device-width, initial-scale=1.0", - }, - }, - }; -} - -pub fn to_html_string( - comptime self: @This(), - comptime components: []const Component, - comptime styles: []const File.Style, -) []const u8 { - var content: []const u8 = "<head>" ++ z_component_code.to_html_string(); - - content = content ++ "<title>" ++ self.title ++ "</title>"; - - inline for (components) |component| { - content = content ++ comptime component.to_html_string(); - } - - inline for (styles) |style| { - content = content ++ comptime style.to_html_string(); - } - - inline for (self.meta) |meta| { - content = content ++ comptime meta.to_html_string(); - } - - content = content ++ "</head>"; - return content; -} diff --git a/src/frontend/root.zig b/src/frontend/root.zig deleted file mode 100644 index 8ff1055..0000000 --- a/src/frontend/root.zig +++ /dev/null @@ -1,13 +0,0 @@ -//! Implementation of the frontend contains mainly javascript files -//! which will be embedded right into the ELF file to improve deployment. - -const std = @import("std"); -pub const Component = @import("Component.zig"); -pub const Document = @import("Document.zig"); -pub const File = @import("File.zig"); -pub const Head = @import("Head.zig"); -pub const Minifier = @import("Minifier.zig"); - -test { - _ = std.testing.refAllDecls(@This()); -} diff --git a/src/frontend/web/hello-world/index.css b/src/frontend/web/hello-world/index.css deleted file mode 100644 index 41b0b29..0000000 --- a/src/frontend/web/hello-world/index.css +++ /dev/null @@ -1,7 +0,0 @@ -#container { - background: #efefef; - padding: 10px; - border-radius: 4px; - user-select: none; - cursor: pointer; -} diff --git a/src/frontend/web/hello-world/index.html b/src/frontend/web/hello-world/index.html deleted file mode 100644 index 047a283..0000000 --- a/src/frontend/web/hello-world/index.html +++ /dev/null @@ -1,6 +0,0 @@ -<div id="container" onclick="clicked()"> - Click Me! - <z-component type="box"> - <span slot="title">Hello World!<span> - </z-component> -</div> diff --git a/src/frontend/web/hello-world/index.js b/src/frontend/web/hello-world/index.js deleted file mode 100644 index 8e55c52..0000000 --- a/src/frontend/web/hello-world/index.js +++ /dev/null @@ -1,3 +0,0 @@ -function clicked() { - console.log('clicked'); -} diff --git a/src/frontend/web/index.html b/src/frontend/web/index.html deleted file mode 100644 index 646cdb0..0000000 --- a/src/frontend/web/index.html +++ /dev/null @@ -1,3 +0,0 @@ -<z-component type="hello-world"> - <p slot="ur-mom">lulululu</p> -</z-component> diff --git a/src/frontend/z-component.js b/src/frontend/z-component.js deleted file mode 100644 index 5e430b5..0000000 --- a/src/frontend/z-component.js +++ /dev/null @@ -1,16 +0,0 @@ -customElements.define( - "z-component", - class extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - } - - connectedCallback() { - const type = this.getAttribute('type'); - const template = document.getElementById(`--${type}`); - const node = document.importNode(template.content, true); - this.shadowRoot.appendChild(node); - } - } -); diff --git a/src/html/Element.zig b/src/html/Element.zig new file mode 100644 index 0000000..8ecf31e --- /dev/null +++ b/src/html/Element.zig @@ -0,0 +1,104 @@ +const Self = @This(); + +pub const Content = union(enum) { + elements: []const Self, + string: []const u8, + void: void, + + pub fn content(string: []const u8) @This() { + return .{ .string = string }; + } + + pub fn children(elements: []const Self) @This() { + return .{ .elements = elements }; + } +}; + +pub const Attribute = struct { + name: []const u8, + value: []const u8, +}; + +tag: []const u8, +attributes: []const Attribute, +content: Content, + +fn attributesFrom(attributes: anytype) []const Attribute { + const T = @TypeOf(attributes); + const info = @typeInfo(T); + + if (info != .@"struct") { + @compileError("struct needed"); + } + + const fields = info.@"struct".fields; + + var attrs: []const Attribute = &.{}; + + for (fields) |field| { + const name = field.name; + const value = @field(attributes, name); + + const next_attr: []const Attribute = &.{ .{ .name = name, .value = value } }; + attrs = attrs ++ next_attr; + } + + return attrs; +} + +pub fn native(name: @EnumLiteral(), attrs: anytype, content: Content) Self { + return .{ + .tag = @tagName(name), + .attributes = attributesFrom(attrs), + .content = content, + }; +} + +pub fn transparent(content: Content) Self { + return .{ + .tag = "", + .attributes = &.{}, + .content = content, + }; +} + +pub fn toHtml(comptime self: Self) []const u8 { + var string: []const u8 = string: { + if (self.tag.len == 0) { + break :string ""; + } + + var string: []const u8 = "<" ++ self.tag; + + inline for (self.attributes) |attr| { + string = string ++ " " ++ attr.name ++ "='" ++ attr.value ++ "'"; + } + + string = string ++ ">"; + + break :string string; + }; + + + switch (self.content) { + .void => {}, + .string => |s| { + string = string ++ s ++ "</" ++ self.tag ++ ">"; + }, + .elements => |els| { + for (els) |el| { + string = string ++ el.toHtml(); + } + + string = string ++ "</" ++ self.tag ++ ">"; + } + } + + string = string ++ if (self.tag.len == 0) "" else "</" ++ self.tag ++ ">"; + + return string; +} + +pub fn toDocument(comptime self: Self) []const u8 { + return "<!DOCTYPE html>" ++ self.toHtml(); +} diff --git a/src/html/root.zig b/src/html/root.zig new file mode 100644 index 0000000..14a9b3c --- /dev/null +++ b/src/html/root.zig @@ -0,0 +1,7 @@ +const std = @import("std"); + +pub const Element = @import("Element.zig"); + +test { + _ = std.testing.refAllDecls(@This()); +} diff --git a/src/http/Server.zig b/src/http/Server.zig index b6e2d90..71037bb 100644 --- a/src/http/Server.zig +++ b/src/http/Server.zig @@ -3,18 +3,48 @@ const std = @import("std"); const Self = @This(); server: std.Io.net.Server, -io: std.Io.Threaded, +threaded: std.Io.Threaded, pub fn init( gpa: std.mem.Allocator, address: std.Io.net.IpAddress, ) !Self { - var io: std.Io.Threaded = .init(gpa, .{}); + var threaded: std.Io.Threaded = .init(gpa, .{}); return .{ - .io = io, - .server = try address.listen(io.io(), .{ .reuse_address = true }), + .threaded = threaded, + .server = try address.listen(threaded.io(), .{ .reuse_address = true }), }; } -pub fn serve(_: *Self) !void {} +pub fn serve(self: *Self, content: []const u8) !void { + var read_buffer: [1024 * 8]u8 = undefined; + var write_buffer: [1024 * 8]u8 = undefined; + + while (true) { + const stream = try self.server.accept(self.threaded.io()); + defer stream.close(self.threaded.io()); + + var reader = stream.reader(self.threaded.io(), &read_buffer); + var writer = stream.writer(self.threaded.io(), &write_buffer); + + var server: std.http.Server = .init(&reader.interface, &writer.interface); + var request = server.receiveHead() catch |e| { + std.debug.print("error: {}\n", .{e}); + continue; + }; + + std.debug.print("{s} {s}\n", .{ @tagName(request.head.method), request.head.target }); + + if (std.mem.eql(u8, request.head.target, "/")) { + request.respond(content, .{ + .status = .ok, + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/html" }, + }, + }) catch continue; + } else { + request.respond("Not Found", .{ .status = .not_found }) catch continue; + } + } +} diff --git a/src/main.zig b/src/main.zig index df0c061..f17214c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -5,10 +5,33 @@ const std = @import("std"); const http = @import("http"); +const html = @import("html"); +const z = @import("z"); + +const badge = @import("web/badge/root.zig"); + +const document: html.Element = .native(.html, .{}, .children(&.{ + .native(.head, .{}, .children(&.{ + .native(.title, .{}, .content("Memora")), + .native(.meta, .{ + .name = "viewport", + .content = "width=device-width, initial-scale=1.0", + }, .void), + z.env(&.{ + badge.component, + }), + })), + .native(.body, .{}, .children(&.{ + z.html.load(@embedFile("web/index.html")), + z.component(.badge, .{}, .children(&.{ + .native(.span, .{ .slot = "content" }, .content("Hello Z (direct)!")), + })), + })), +})); pub fn main(init: std.process.Init) !void { var server: http.Server = try .init(init.gpa, try .parseLiteral("127.0.0.1:8080")); - try server.serve(); + try server.serve(comptime document.toDocument()); } test { diff --git a/src/web/badge/index.css b/src/web/badge/index.css new file mode 100644 index 0000000..3f237d6 --- /dev/null +++ b/src/web/badge/index.css @@ -0,0 +1,5 @@ +#container { + background: red; + padding: 5px; + border-radius: 5px; +} diff --git a/src/web/badge/index.html b/src/web/badge/index.html new file mode 100644 index 0000000..dcb38e1 --- /dev/null +++ b/src/web/badge/index.html @@ -0,0 +1,3 @@ +<div id="container"> + <slot name="content"></slot> +</div> diff --git a/src/web/badge/index.js b/src/web/badge/index.js new file mode 100644 index 0000000..60fbac5 --- /dev/null +++ b/src/web/badge/index.js @@ -0,0 +1 @@ +Z.by_id('sdf') diff --git a/src/web/badge/root.zig b/src/web/badge/root.zig new file mode 100644 index 0000000..5e8ea25 --- /dev/null +++ b/src/web/badge/root.zig @@ -0,0 +1,8 @@ +const z = @import("z"); + +pub const component: z.Component = .{ + .name = .badge, + .body = @embedFile("index.html"), + .style = @embedFile("index.css"), + .script = @embedFile("index.js"), +}; diff --git a/src/frontend/web/index.css b/src/web/index.css index a93ea47..a93ea47 100644 --- a/src/frontend/web/index.css +++ b/src/web/index.css diff --git a/src/web/index.html b/src/web/index.html new file mode 100644 index 0000000..3bf9953 --- /dev/null +++ b/src/web/index.html @@ -0,0 +1,3 @@ +<z-component type="badge"> + <span slot="content">Hello Z!</span> +</z-component> diff --git a/src/web/index.js b/src/web/index.js new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/web/index.js diff --git a/src/z/Component.zig b/src/z/Component.zig new file mode 100644 index 0000000..3211335 --- /dev/null +++ b/src/z/Component.zig @@ -0,0 +1,18 @@ +const std = @import("std"); +const html = @import("html"); +const z = @import("root.zig"); + +name: @EnumLiteral(), +body: []const u8, +style: []const u8, +script: []const u8, + +pub fn toElement(comptime self: @This()) html.Element { + return .transparent(.children(&.{ + .native(.template, .{ .id = "@" ++ @tagName(self.name) }, .children(&.{ + z.style.load(self.style), + z.body.load(self.body), + })), + z.script.load("Zenv.constructors['component__" ++ @tagName(self.name) ++ "'] = (Z) => {" ++ self.script ++ "}"), + })); +} diff --git a/src/z/File.zig b/src/z/File.zig new file mode 100644 index 0000000..cd7094f --- /dev/null +++ b/src/z/File.zig @@ -0,0 +1,18 @@ +const Minifier = @import("Minifier.zig"); +const Element = @import("html").Element; + +tag: ?@EnumLiteral(), +minifier: Minifier, + +pub fn load(comptime self: @This(), comptime content: []const u8) Element { + if (self.tag) |tag| { + return .native( + tag, + .{}, + .content(comptime self.minifier.minify(content)), + ); + } else { + return .transparent(.content(comptime self.minifier.minify(content))); + } +} + diff --git a/src/frontend/Minifier.zig b/src/z/Minifier.zig index 65dd9fb..65dd9fb 100644 --- a/src/frontend/Minifier.zig +++ b/src/z/Minifier.zig diff --git a/src/z/root.zig b/src/z/root.zig new file mode 100644 index 0000000..26bbffc --- /dev/null +++ b/src/z/root.zig @@ -0,0 +1,52 @@ +const std = @import("std"); + +pub const Minifier = @import("Minifier.zig"); +pub const Component = @import("Component.zig"); +pub const File = @import("File.zig"); + +const Element = @import("html").Element; + +pub const script: File = .{ + .tag = .script, + .minifier = .js, +}; +pub const style: File = .{ + .tag = .style, + .minifier = .css, +}; +pub const body: File = .{ + .tag = .body, + .minifier = .html, +}; + +pub const html: File = .{ + .tag = null, + .minifier = .html, +}; + +pub fn env(comptime components: []const Component) Element { + var children: []const Element = &.{script.load(@embedFile("z.js"))}; + + inline for (components) |c| { + const child: []const Element = &.{c.toElement()}; + children = children ++ child; + } + + return .transparent(.children(children)); +} + +pub fn component(kind: @EnumLiteral(), attrs: anytype, content: Element.Content) Element { + var element: Element = .native(.@"z-component", attrs, content); + + const type_attr: []const Element.Attribute = &.{.{ + .name = "type", + .value = @tagName(kind), + }}; + element.attributes = type_attr ++ element.attributes; + + return element; +} + +test { + _ = std.testing.refAllDecls(@This()); +} diff --git a/src/z/z.js b/src/z/z.js new file mode 100644 index 0000000..a5a1b92 --- /dev/null +++ b/src/z/z.js @@ -0,0 +1,78 @@ +class ZComponent extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + const type = this.getAttribute('type'); + + if (type == null) { + throw `component without any type given.`; + } + + const template = document.getElementById(`@${type}`); + + if (template == null) { + throw `component '${type}' does not exist.`; + } + + const node = document.importNode(template.content, true); + this.shadowRoot.appendChild(node); + + Zenv.constructors[`component__${type}`](new Zenv(this)); + } +} + +class Zenv { + static constructors = {}; + + constructor(doc) { + this.document = doc; + } + + component(type, slots) { + const element = document.createElement('z-component'); + element.setAttribute('type', type); + + for (const name in slots) { + const slot = slots[name]; + slot.setAttribute('slot', name); + element.appendChild(slot); + } + + return element; + } + + populate_attributes(obj, attrs) { + for (const attr in attrs) { + if (typeof(obj[attr]) == 'object' && typeof(attrs[attr]) == 'object') { + populate_attributes(obj[attr], attrs[attr]); + } else { + obj[attr] = attrs[attr]; + } + } + } + + native(name, options) { + const element = document.createElement(name); + Zenv.populate_attributes(element, options); + return element; + } + + by_id(id) { + return this.document.shadowRoot.getElementById(id); + } + + by_selector(query) { + return this.document.shadowRoot.querySelector(query); + } + + by_selector_all(query) { + return this.document.shadowRoot.querySelectorAll(query); + } +} + +Z = new Zenv(document.body); + +customElements.define("z-component", ZComponent); |