wip(ide): devtool data integration (#56)

This commit is contained in:
Nurul Huda (Apon) 2026-03-01 01:47:41 +06:00 committed by GitHub
parent ee456aaade
commit f60f735f41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 939 additions and 376 deletions

View File

@ -31,17 +31,10 @@ jobs:
./zig-out/bin/zx --help || echo "Executable built successfully"
shell: bash
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: zx-${{ matrix.os }}
path: zig-out/
retention-days: 1
test:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: build
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
@ -55,16 +48,6 @@ jobs:
with:
version: 0.15.2
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: zx-${{ matrix.os }}
path: zig-out/
- name: Make executable (Unix)
if: runner.os != 'Windows'
run: chmod +x zig-out/bin/zx
- name: Run tests
run: zig build test
env:

View File

@ -1 +1,2 @@
pages/
pages/
assets/

View File

@ -49,6 +49,6 @@
}
],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
"extension_pages": "script-src 'self' 'wasm-unsafe-eval' http://localhost:* http://127.0.0.1:*; connect-src 'self' http://localhost:* http://127.0.0.1:* https://cdn.jsdelivr.net; object-src 'self'"
}
}

View File

@ -1,30 +1,66 @@
(function() {
var handle = document.querySelector('.devtools-resize-handle');
if (!handle) return;
var sidebar = handle.previousElementSibling;
var dragging = false;
(function () {
const container = document.querySelector('.devtools-container');
const handle = document.querySelector('.devtools-resize-handle');
if (!handle || !container) return;
handle.addEventListener('mousedown', function(e) {
const sidebar = handle.previousElementSibling;
let dragging = false;
function startDrag(e) {
if (e.type === 'touchstart' && e.touches.length !== 1) return;
e.preventDefault();
dragging = true;
const isColumn = getComputedStyle(container).flexDirection === 'column';
container.classList.add(isColumn ? 'devtools-dragging-v' : 'devtools-dragging');
handle.classList.add('active');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
function handleDrag(clientX, clientY) {
if (!dragging) return;
const rect = container.getBoundingClientRect();
const isColumn = getComputedStyle(container).flexDirection === 'column';
if (isColumn) {
const pct = ((clientY - rect.top) / rect.height) * 100;
const clamped = Math.max(15, Math.min(85, pct));
sidebar.style.flex = '0 0 ' + clamped + '%';
sidebar.style.height = clamped + '%';
sidebar.style.width = '100%';
} else {
const pct = ((clientX - rect.left) / rect.width) * 100;
const clamped = Math.max(15, Math.min(85, pct));
sidebar.style.flex = '0 0 ' + clamped + '%';
sidebar.style.width = clamped + '%';
sidebar.style.height = '100%';
}
}
handle.addEventListener('mousedown', startDrag);
handle.addEventListener('touchstart', startDrag, { passive: false });
document.addEventListener('mousemove', function (e) {
handleDrag(e.clientX, e.clientY);
});
document.addEventListener('mousemove', function(e) {
if (!dragging) return;
var container = sidebar.parentNode;
var rect = container.getBoundingClientRect();
var w = Math.min(Math.max(200, e.clientX - rect.left), rect.width - 200);
sidebar.style.width = w + 'px';
});
document.addEventListener('touchmove', function (e) {
if (!dragging || e.touches.length !== 1) return;
const touch = e.touches[0];
handleDrag(touch.clientX, touch.clientY);
}, { passive: false });
document.addEventListener('mouseup', function() {
if (!dragging) return;
dragging = false;
handle.classList.remove('active');
document.body.style.cursor = '';
document.body.style.userSelect = '';
});
function stopDrag() {
if (dragging) {
dragging = false;
container.classList.remove('devtools-dragging');
container.classList.remove('devtools-dragging-v');
handle.classList.remove('active');
}
}
document.addEventListener('mouseup', stopDrag);
document.addEventListener('touchend', stopDrag);
})();

View File

@ -163,7 +163,7 @@ a {
/* Layout */
.app-layout {
display: grid;
grid-template-columns: 60px 1fr;
grid-template-columns: 48px 1fr;
height: 100vh;
width: 100vw;
overflow: hidden;
@ -178,14 +178,14 @@ a {
height: 100%;
overflow-y: auto;
overflow-x: visible;
width: 60px;
width: 48px;
position: relative;
z-index: 10001;
}
.sidebar-header {
height: 48px;
padding: 0 16px;
padding: 0 10px;
border-bottom: 1px solid var(--border-color);
font-weight: 500;
font-size: 1.125rem;
@ -204,12 +204,12 @@ a {
.nav-text {
position: fixed;
left: 70px;
left: 54px;
background-color: #1a1a1a;
color: var(--text-base);
padding: 10px 16px;
padding: 6px 10px;
border-radius: 6px;
font-size: 14px;
font-size: 12px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
@ -229,8 +229,8 @@ a {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 12px 8px 12px 6px;
margin: 4px 8px;
padding: 8px 4px 8px 4px;
margin: 4px 6px;
color: var(--text-muted);
transition: all 0.2s;
font-weight: 500;
@ -252,11 +252,11 @@ a {
}
.nav-icon {
width: 24px;
min-width: 24px;
width: 20px;
min-width: 20px;
text-align: center;
margin-right: 8px;
font-size: 20px;
margin-right: 0;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
@ -748,6 +748,27 @@ input:checked+.slider:before {
overflow: hidden;
}
@media (max-width: 768px) {
.devtools-container {
flex-direction: column;
}
.devtools-container > .sidebar {
width: 100% !important;
height: 50%;
min-width: 0 !important;
min-height: 15% !important;
border-right: none;
border-bottom: 1px solid #333;
}
.devtools-resize-handle {
width: 100% !important;
height: 4px !important;
cursor: row-resize !important;
}
}
.devtools-container > .sidebar {
width: 500px;
min-width: 200px;
@ -772,6 +793,25 @@ input:checked+.slider:before {
background: #00d9ff;
}
.devtools-dragging {
cursor: col-resize !important;
user-select: none !important;
}
.devtools-dragging-v {
cursor: row-resize !important;
user-select: none !important;
}
.component-state-wrapper {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.search-box {
padding: 8px;
border-bottom: 1px solid #333;
@ -1127,6 +1167,7 @@ label.state-item {
flex-direction: column;
background: #000000;
color: #e5e5e5;
overflow-y: auto;
}
.content {
@ -1180,6 +1221,8 @@ label.state-item {
border-radius: 5px;
padding: 20px;
transition: transform 0.3s;
max-height: calc(100vh - 150px);
overflow-y: auto;
}
.routes-section:hover {
@ -1291,6 +1334,11 @@ label.state-item {
border: 1px solid rgba(249, 62, 62, 0.3);
}
.routes-children {
flex: 1;
overflow-y: auto;
}
.route-path {
color: #e5e5e5;
font-family: monospace;
@ -1298,8 +1346,10 @@ label.state-item {
.sidebar-logo {
color: #00d9ff;
min-width: 28px;
min-width: 24px;
font-weight: 900;
display: flex;
justify-content: center;
}
.sidebar-nav {

View File

@ -1,13 +1,12 @@
/// Centralized data for the devtool.
/// Pure data only no logic or computed values.
pub const ComponentMeta = struct {
version: []const u8 = "v1.0.0",
setup_items: []const StateItem = &[_]StateItem{},
setup_other_items: []const StateItem = &[_]StateItem{},
template_refs_items: []const StateItem = &[_]StateItem{},
prop_items: []const StateItem = &[_]StateItem{},
signal_items: []const StateItem = &[_]StateItem{},
action_items: []const StateItem = &[_]StateItem{},
};
pub const Component = struct {
id: []const u8 = "",
name: []const u8,
has_children: bool,
children: []const Component,
@ -21,123 +20,187 @@ pub const Route = struct {
path: []const u8,
};
pub const StateItem = struct {
key: []const u8,
value: []const u8,
meta: []const u8,
children: []const StateItem = &[_]StateItem{},
};
pub const StateItem = zx.Component.Serializable.StateItem;
pub const components = [_]Component{
.{ .name = "App", .has_children = true, .selected = true, .badge = "fragment", .meta = ComponentMeta{
.version = "v1.0.0",
.setup_items = &.{
.{ .key = "replRef", .value = "Object", .meta = "(Ref)", .children = &[_]StateItem{
.{ .key = "value", .value = "null", .meta = "" },
.{ .key = "__v_isRef", .value = "true", .meta = "" },
} },
.{ .key = "AUTO_SAVE_STORAGE_KEY", .value = "\"zx-sfc-playground-auto-save\"", .meta = "" },
.{ .key = "initAutoSave", .value = "true", .meta = "" },
.{ .key = "autoSave", .value = "true", .meta = "(Ref)" },
.{ .key = "productionMode", .value = "false", .meta = "(Ref)" },
.{ .key = "zxVersion", .value = "null", .meta = "(Ref)" },
.{ .key = "importMap", .value = "Object", .meta = "(Computed)", .children = &[_]StateItem{
.{ .key = "imports", .value = "Object", .meta = "", .children = &[_]StateItem{
.{ .key = "zx", .value = "\"https://cdn.jsdelivr.net/npm/zx\"", .meta = "" },
} },
} },
.{ .key = "hash", .value = "eNp9UU1LAzEQ/StjLqugXURPZVtQKaBgHFRW85FJ2p9vUBbKS2bWw7H93kqw1Q...", .meta = "" },
.{ .key = "sfcOptions", .value = "Object", .meta = "(Computed)", .children = &[_]StateItem{
.{ .key = "script", .value = "Object", .meta = "" },
.{ .key = "template", .value = "Object", .meta = "" },
} },
.{ .key = "store", .value = "Reactive", .meta = "", .children = &[_]StateItem{
.{ .key = "theme", .value = "\"dark\"", .meta = "(Ref)" },
.{ .key = "isVaporSupported", .value = "false", .meta = "(Ref)" },
} },
.{ .key = "previewOptions", .value = "Object", .meta = "(Computed)", .children = &[_]StateItem{
.{ .key = "headHTML", .value = "\"\"", .meta = "" },
} },
},
.setup_other_items = &.{
.{ .key = "setVH", .value = "fn i()", .meta = "" },
.{ .key = "toggleProdMode", .value = "fn p()", .meta = "" },
.{ .key = "toggleSSR", .value = "fn f()", .meta = "" },
.{ .key = "toggleAutoSave", .value = "fn m()", .meta = "" },
.{ .key = "reloadPage", .value = "fn _()", .meta = "" },
.{ .key = "toggleTheme", .value = "fn y(I)", .meta = "" },
.{ .key = "Header", .value = "Header", .meta = "" },
.{ .key = "Repl", .value = "Object", .meta = "", .children = &[_]StateItem{
.{ .key = "setup", .value = "fn()", .meta = "" },
.{ .key = "render", .value = "fn()", .meta = "" },
} },
.{ .key = "Monaco", .value = "Object", .meta = "", .children = &[_]StateItem{
.{ .key = "editor", .value = "null", .meta = "(Ref)" },
} },
},
.template_refs_items = &.{
.{ .key = "replRef", .value = "Object", .meta = "", .children = &[_]StateItem{
.{ .key = "$el", .value = "<div>", .meta = "" },
} },
},
}, .children = &[_]Component{
.{ .name = "Header", .has_children = true, .meta = ComponentMeta{
.version = "v1.0.0",
.setup_items = &.{
.{ .key = "title", .value = "\"ZX Playground\"", .meta = "(Ref)" },
.{ .key = "showNav", .value = "true", .meta = "(Ref)" },
.{ .key = "theme", .value = "\"dark\"", .meta = "(Ref)" },
.{ .key = "logo", .value = "Object", .meta = "(Ref)", .children = &[_]StateItem{
.{ .key = "src", .value = "\"/assets/logo.svg\"", .meta = "" },
.{ .key = "alt", .value = "\"ZX Logo\"", .meta = "" },
} },
.{ .key = "navItems", .value = "Object", .meta = "(Computed)", .children = &[_]StateItem{
.{ .key = "docs", .value = "\"/docs\"", .meta = "" },
.{ .key = "playground", .value = "\"/playground\"", .meta = "" },
.{ .key = "github", .value = "\"https://github.com\"", .meta = "" },
} },
.{ .key = "version", .value = "\"v1.0.0\"", .meta = "(Computed)" },
.{ .key = "isMenuOpen", .value = "false", .meta = "(Ref)" },
},
.setup_other_items = &.{
.{ .key = "toggleTheme", .value = "fn y(I)", .meta = "" },
.{ .key = "toggleMenu", .value = "fn m()", .meta = "" },
.{ .key = "VersionSelect", .value = "Object", .meta = "", .children = &[_]StateItem{
.{ .key = "setup", .value = "fn()", .meta = "" },
.{ .key = "render", .value = "fn()", .meta = "" },
} },
},
.template_refs_items = &.{
.{ .key = "headerRef", .value = "Object", .meta = "", .children = &[_]StateItem{
.{ .key = "$el", .value = "<header>", .meta = "" },
} },
},
}, .children = &[_]Component{
.{ .name = "VersionSelect", .has_children = false, .children = &[_]Component{} },
.{ .name = "VersionSelect", .has_children = false, .children = &[_]Component{} },
.{ .name = "Sun", .has_children = false, .children = &[_]Component{} },
.{ .name = "Moon", .has_children = false, .children = &[_]Component{} },
.{ .name = "Share", .has_children = false, .children = &[_]Component{} },
.{ .name = "Reload", .has_children = false, .children = &[_]Component{} },
.{ .name = "Download", .has_children = false, .children = &[_]Component{} },
.{ .name = "GitHub", .has_children = false, .children = &[_]Component{} },
} },
.{
.name = "Repl",
.has_children = true,
.children = &[_]Component{
.{
.name = "App",
.has_children = true,
.selected = true,
.badge = "fragment",
.meta = ComponentMeta{
.prop_items = &.{
.{
.name = "SplitPane",
.has_children = true,
.children = &[_]Component{
.{ .name = "Panes", .has_children = false, .children = &[_]Component{} },
.{ .name = "PanesTwo", .has_children = false, .children = &[_]Component{} },
.{ .name = "PanesThree", .has_children = false, .children = &[_]Component{} },
.key = "replRef",
.value = "Object",
.meta = "(Ref)",
.children = &[_]StateItem{
.{ .key = "value", .value = "null", .meta = "" },
.{ .key = "__v_isRef", .value = "true", .meta = "" },
},
},
.{ .key = "AUTO_SAVE_STORAGE_KEY", .value = "\"zx-sfc-playground-auto-save\"", .meta = "" },
.{ .key = "initAutoSave", .value = "true", .meta = "" },
.{ .key = "autoSave", .value = "true", .meta = "(Ref)" },
.{ .key = "productionMode", .value = "false", .meta = "(Ref)" },
.{ .key = "zxVersion", .value = "null", .meta = "(Ref)" },
.{
.key = "importMap",
.value = "Object",
.meta = "(Computed)",
.children = &[_]StateItem{
.{ .key = "imports", .value = "Object", .meta = "", .children = &[_]StateItem{
.{ .key = "zx", .value = "\"https://cdn.jsdelivr.net/npm/zx\"", .meta = "" },
} },
},
},
.{ .key = "hash", .value = "eNp9UU1LAzEQ/StjLqugXURPZVtQKaBgHFRW85FJ2p9vUBbKS2bWw7H93kqw1Q...", .meta = "" },
.{
.key = "sfcOptions",
.value = "Object",
.meta = "(Computed)",
.children = &[_]StateItem{
.{ .key = "script", .value = "Object", .meta = "" },
.{ .key = "template", .value = "Object", .meta = "" },
},
},
.{
.key = "store",
.value = "Reactive",
.meta = "",
.children = &[_]StateItem{
.{ .key = "theme", .value = "\"dark\"", .meta = "(Ref)" },
.{ .key = "isVaporSupported", .value = "false", .meta = "(Ref)" },
},
},
.{
.key = "previewOptions",
.value = "Object",
.meta = "(Computed)",
.children = &[_]StateItem{
.{ .key = "headHTML", .value = "\"\"", .meta = "" },
},
},
},
.signal_items = &.{
.{ .key = "setVH", .value = "fn i()", .meta = "" },
.{ .key = "toggleProdMode", .value = "fn p()", .meta = "" },
.{ .key = "toggleSSR", .value = "fn f()", .meta = "" },
.{ .key = "toggleAutoSave", .value = "fn m()", .meta = "" },
.{ .key = "reloadPage", .value = "fn _()", .meta = "" },
.{ .key = "toggleTheme", .value = "fn y(I)", .meta = "" },
.{ .key = "Header", .value = "Header", .meta = "" },
.{
.key = "Repl",
.value = "Object",
.meta = "",
.children = &[_]StateItem{
.{ .key = "setup", .value = "fn()", .meta = "" },
.{ .key = "render", .value = "fn()", .meta = "" },
},
},
.{
.key = "Monaco",
.value = "Object",
.meta = "",
.children = &[_]StateItem{
.{ .key = "editor", .value = "null", .meta = "(Ref)" },
},
},
},
.action_items = &.{
.{
.key = "replRef",
.value = "Object",
.meta = "",
.children = &[_]StateItem{
.{ .key = "$el", .value = "<div>", .meta = "" },
},
},
},
},
} },
.children = &[_]Component{
.{
.name = "Header",
.has_children = true,
.meta = ComponentMeta{
.prop_items = &.{
.{ .key = "title", .value = "\"ZX Playground\"", .meta = "(Ref)" },
.{ .key = "showNav", .value = "true", .meta = "(Ref)" },
.{ .key = "theme", .value = "\"dark\"", .meta = "(Ref)" },
.{
.key = "logo",
.value = "Object",
.meta = "(Ref)",
.children = &[_]StateItem{
.{ .key = "src", .value = "\"/assets/logo.svg\"", .meta = "" },
.{ .key = "alt", .value = "\"ZX Logo\"", .meta = "" },
},
},
.{
.key = "navItems",
.value = "Object",
.meta = "(Computed)",
.children = &[_]StateItem{
.{ .key = "docs", .value = "\"/docs\"", .meta = "" },
.{ .key = "playground", .value = "\"/playground\"", .meta = "" },
.{ .key = "github", .value = "\"https://github.com\"", .meta = "" },
},
},
.{ .key = "isMenuOpen", .value = "false", .meta = "(Ref)" },
},
.signal_items = &.{
.{ .key = "toggleTheme", .value = "fn y(I)", .meta = "" },
.{ .key = "toggleMenu", .value = "fn m()", .meta = "" },
.{
.key = "VersionSelect",
.value = "Object",
.meta = "",
.children = &[_]StateItem{
.{ .key = "setup", .value = "fn()", .meta = "" },
.{ .key = "render", .value = "fn()", .meta = "" },
},
},
},
.action_items = &.{
.{
.key = "headerRef",
.value = "Object",
.meta = "",
.children = &[_]StateItem{
.{ .key = "$el", .value = "<header>", .meta = "" },
},
},
},
},
.children = &[_]Component{
.{ .name = "VersionSelect", .has_children = false, .children = &[_]Component{} },
.{ .name = "VersionSelect", .has_children = false, .children = &[_]Component{} },
.{ .name = "Sun", .has_children = false, .children = &[_]Component{} },
.{ .name = "Moon", .has_children = false, .children = &[_]Component{} },
.{ .name = "Share", .has_children = false, .children = &[_]Component{} },
.{ .name = "Reload", .has_children = false, .children = &[_]Component{} },
.{ .name = "Download", .has_children = false, .children = &[_]Component{} },
.{ .name = "GitHub", .has_children = false, .children = &[_]Component{} },
},
},
.{
.name = "Repl",
.has_children = true,
.children = &[_]Component{
.{
.name = "SplitPane",
.has_children = true,
.children = &[_]Component{
.{ .name = "Panes", .has_children = false, .children = &[_]Component{} },
.{ .name = "PanesTwo", .has_children = false, .children = &[_]Component{} },
.{ .name = "PanesThree", .has_children = false, .children = &[_]Component{} },
},
},
},
},
},
},
};
pub const routes = [_]Route{
@ -150,3 +213,98 @@ pub const routes = [_]Route{
.{ .method = "GET", .path = "/docs" },
.{ .method = "GET", .path = "/settings" },
};
pub fn fromSerializable(allocator: std.mem.Allocator, s: zx.Component.Serializable, path: []const u8) anyerror!Component {
var name: []const u8 = "unknown";
var badge: []const u8 = "";
if (s.component) |c| {
name = c;
} else if (s.tag) |t| {
name = @tagName(t);
} else if (s.text) |_| {
name = "text";
badge = "text";
}
var children: []const Component = &[_]Component{};
if (s.children) |sc| {
var children_mut = try allocator.alloc(Component, sc.len);
for (sc, 0..) |child_s, i| {
const child_path = try std.fmt.allocPrint(allocator, "{s}.{d}", .{ path, i });
children_mut[i] = try fromSerializable(allocator, child_s, child_path);
}
children = children_mut;
}
var meta: ?ComponentMeta = null;
if (s.props) |p| {
var props_list = std.ArrayList(StateItem).empty;
var signals_list = std.ArrayList(StateItem).empty;
for (p) |item| {
if (std.mem.eql(u8, item.meta, "(Ref)") or std.mem.eql(u8, item.meta, "(Computed)")) {
try signals_list.append(allocator, item);
} else {
try props_list.append(allocator, item);
}
}
meta = ComponentMeta{
.prop_items = try props_list.toOwnedSlice(allocator),
.signal_items = try signals_list.toOwnedSlice(allocator),
};
}
return Component{
.id = path,
.name = name,
.children = children,
.has_children = children.len > 0,
.badge = badge,
.meta = meta,
};
}
pub fn fromSerializableSlice(allocator: std.mem.Allocator, sc: []const zx.Component.Serializable) anyerror![]const Component {
var children = try allocator.alloc(Component, sc.len);
for (sc, 0..) |child_s, i| {
const path = try std.fmt.allocPrint(allocator, "{d}", .{i});
children[i] = try fromSerializable(allocator, child_s, path);
}
var root_page = try allocator.alloc(Component, 1);
root_page[0] = Component{
.id = "0.root.layout.page",
.name = "Page",
.children = children,
.has_children = children.len > 0,
.badge = "",
.meta = null,
};
var root_layout = try allocator.alloc(Component, 1);
root_layout[0] = Component{
.id = "0.root.layout",
.name = "Layout",
.children = root_page,
.has_children = root_page.len > 0,
.badge = "",
.meta = null,
};
var root_component = try allocator.alloc(Component, 1);
root_component[0] = Component{
.id = "0.root",
.name = "App",
.children = root_layout,
.has_children = root_layout.len > 0,
.badge = "fragment",
.meta = null,
};
return root_component;
}
const zx = @import("zx");
const std = @import("std");

View File

@ -21,9 +21,9 @@ pub fn Layout(ctx: zx.LayoutContext, children: zx.Component) zx.Component {
</a>
)}
</nav>
<div class="sidebar-footer">
v1.0.0
</div>
// <div class="sidebar-footer">
// {zx.info.version}
// </div>
</aside>
<main class="main-content">
{children}

View File

@ -10,7 +10,7 @@ const component_count_label = std.fmt.comptimePrint("{d} components", .{countCom
const route_count_label = std.fmt.comptimePrint("{d} Routes", .{data.routes.len});
const features = [_]Feature{
.{ .icon_fn = icons.Lightning, .title = data.components[0].meta.?.version, .link = "overview" },
.{ .icon_fn = icons.Lightning, .title = zx.info.version, .link = "overview" },
.{ .icon_fn = icons.Hive, .title = component_count_label, .link = "/" },
.{ .icon_fn = icons.Workflow, .title = route_count_label, .link = "routes" },
};
@ -30,7 +30,7 @@ pub fn Page(ctx: zx.PageContext) zx.Component {
</p>
<p class="hero-description">
Developer Tools for Ziex Framework
<code class="version-badge">{data.components[0].meta.?.version}</code>
<code class="version-badge">{zx.info.version}</code>
</p>
<div class="features-grid">

View File

@ -1,18 +1,20 @@
const components = data.components;
var components: []const data.Component = &.{};
var inputvalue: []const u8 = "";
var stateFilter: []const u8 = "";
var selected_component = zx.Signal([]const u8).init("App");
var selected_component = zx.Signal([]const u8).init("0");
// Helper function to find a component by name recursively
fn findComponentByName(comps: []const data.Component, name: []const u8) ?data.Component {
var fetched = false;
// Helper function to find a component by ID recursively
fn findComponentById(comps: []const data.Component, id: []const u8) ?data.Component {
fetch();
for (comps) |comp| {
if (std.mem.eql(u8, comp.name, name)) {
if (std.mem.eql(u8, comp.id, id)) {
return comp;
}
if (comp.has_children) {
if (findComponentByName(comp.children, name)) |found| {
if (findComponentById(comp.children, id)) |found| {
return found;
}
}
@ -20,6 +22,29 @@ fn findComponentByName(comps: []const data.Component, name: []const u8) ?data.Co
return null;
}
fn fetch() void {
if (fetched) return;
fetched = true;
_ = zx.fetch(.wasm(&onFetchText), zx.client_allocator, "http://localhost:5588/.well-known/_zx/devtool?path=/", .{ .method = .GET }) catch {};
}
fn onFetchText(res: ?*zx.Fetch.Response, _: ?zx.Fetch.FetchError) void {
if (res) |r| {
defer r.deinit();
if (r.text()) |p| {
const parsed = zx.prop.parse(zx.Component.Serializable, zx.client_allocator, p);
if (parsed.children) |sc| {
const mapped = data.fromSerializableSlice(zx.client_allocator, sc) catch unreachable;
std.log.info("Fetched {d} components", .{mapped.len});
components = mapped;
}
} else |_| {}
}
zx.requestRender();
}
const ComponentSearchInputProps = struct { };
const ComponentStateProps = struct { };
const ComponentTreeProps = struct { };
@ -36,10 +61,11 @@ pub fn ComponentSearchInput(
pub fn ComponentTree(
ctx: *zx.ComponentCtx(ComponentTreeProps),
) zx.Component {
const sel = selected_component.get();
return (
<div @allocator={ctx.allocator} class="component-tree">
<div class="component-list">
{for (components) |comp| (<ComponentItem allocator={ctx.allocator} name={comp.name} has_children={comp.has_children} children={comp.children} depth={0} selected={comp.selected} badge={comp.badge} />)}
{for (components) |comp| (<ComponentItem allocator={ctx.allocator} id={comp.id} name={comp.name} has_children={comp.has_children} children={comp.children} depth={0} selected={std.mem.eql(u8, comp.id, sel)} badge={comp.badge} />)}
</div>
</div>
);
@ -49,16 +75,16 @@ pub fn ComponentState(
ctx: *zx.ComponentCtx(ComponentStateProps),
) zx.Component {
const sel = selected_component.get();
const maybe_component = findComponentByName(&components, sel);
const maybe_component = findComponentById(components, sel);
if (maybe_component) |comp| {
if (comp.meta) |metadata| {
return (
<div @allocator={ctx.allocator} class="component-state-wrapper">
<div @allocator={ctx.allocator} class="state-content">
<StateSection allocator={ctx.allocator} title="Props" items={metadata.setup_items} />
<StateSection allocator={ctx.allocator} title="Signals" items={metadata.setup_other_items} />
<StateSection allocator={ctx.allocator} title="Actions" items={metadata.template_refs_items} />
<StateSection allocator={ctx.allocator} title="Props" items={metadata.prop_items} />
<StateSection allocator={ctx.allocator} title="Signals" items={metadata.signal_items} />
<StateSection allocator={ctx.allocator} title="Actions" items={metadata.action_items} />
</div>
</div>
);
@ -69,7 +95,7 @@ pub fn ComponentState(
<div @allocator={ctx.allocator} class="component-state-wrapper">
<div @allocator={ctx.allocator} class="state-content">
<div class="state-empty">
<h2>{sel}</h2>
<h2>{if (maybe_component) |c| c.name else sel}</h2>
<p>{"No state data available for this component."}</p>
</div>
</div>
@ -92,14 +118,14 @@ pub fn ComponentStateFilter(
) zx.Component {
return (
<div @allocator={ctx.allocator} class="filter-wrapper">
<input @allocator={ctx.allocator} oninput={handleStateFilterInput} type="text" placeholder="Filter State..." class="filter-input" />
<input @allocator={ctx.allocator} oninput={handleStateFilterInput} type="text" placeholder="Search..." class="filter-input" />
</div>
);
}
pub fn handleComponentClick(ctx: zx.EventContext) void {
const name = ctx.value() orelse "App";
selected_component.set(name);
const id = ctx.value() orelse "0";
selected_component.set(id);
zx.requestRender();
}
@ -124,14 +150,14 @@ pub fn Page(ctx: zx.PageContext) zx.Component {
<div class="state-panel">
<div class="state-header">
<span class="state-title" id="state-title">{&selected_component}</span>
// <span class="state-title" id="state-title">{&selected_component}</span>
<ComponentStateFilter @rendering={.client} />
<div class="state-actions">
{icons.StateEye(ctx.arena)}
{icons.StateArrows(ctx.arena)}
{icons.StateList(ctx.arena)}
{icons.StateExport(ctx.arena)}
</div>
// <div class="state-actions">
// {icons.StateEye(ctx.arena)}
// {icons.StateArrows(ctx.arena)}
// {icons.StateList(ctx.arena)}
// {icons.StateExport(ctx.arena)}
// </div>
</div>
<ComponentState @rendering={.client} />
@ -142,25 +168,28 @@ pub fn Page(ctx: zx.PageContext) zx.Component {
}
const Component = data.Component;
const ComponentItemProps = struct { name: []const u8, has_children: bool, children: []const Component, depth: usize = 0, selected: bool = false, badge: []const u8 = "" };
const ComponentItemProps = struct { id: []const u8, name: []const u8, has_children: bool, children: []const Component, depth: usize = 0, selected: bool = false, badge: []const u8 = "" };
fn ComponentItem(allocator: zx.Allocator, props: ComponentItemProps) zx.Component {
const id = std.fmt.allocPrint(allocator, "comp-{d}-{s}", .{ props.depth, props.name }) catch "";
const sel = selected_component.get();
const dom_id = std.fmt.allocPrint(allocator, "comp-{s}", .{props.id}) catch "";
const padding = props.depth * 24 + 16;
const style_str = std.fmt.allocPrint(allocator, "padding-left: {d}px", .{padding}) catch "";
const item_class = if (props.selected) "component-root" else "component-item";
const name_class = if (props.selected) "component-name-root" else "component-name";
const is_selected = std.mem.eql(u8, props.id, sel);
const item_class = if (is_selected) "component-root" else "component-item";
const name_class = if (is_selected) "component-name-root" else "component-name";
const group_class = getComponentGroupClass(props.name, props.has_children, props.children);
return (
<div @allocator={allocator} class={group_class}>
{if (props.has_children) (
<>
<input type="checkbox" id={id} class="component-toggle" checked />
<input type="checkbox" id={dom_id} class="component-toggle" checked />
<div class={item_class} style={style_str} >
<label for={id} class="component-toggle-label">
<label for={dom_id} class="component-toggle-label">
<span class="tree-arrow"></span>
</label>
<button value={props.name} onclick={handleComponentClick} class="component-select-btn">
<button value={props.id} onclick={handleComponentClick} class="component-select-btn">
<span class="bracket">{"<"}</span>
<span class={name_class}>{props.name}</span>
<span class="bracket">{">"}</span>
@ -169,13 +198,13 @@ fn ComponentItem(allocator: zx.Allocator, props: ComponentItemProps) zx.Componen
</div>
<div class="component-children">
{for (props.children) |child| (
<ComponentItem allocator={allocator} name={child.name} has_children={child.has_children} children={child.children} depth={props.depth + 1} selected={child.selected} badge={child.badge} />
<ComponentItem allocator={allocator} id={child.id} name={child.name} has_children={child.has_children} children={child.children} depth={props.depth + 1} selected={std.mem.eql(u8, child.id, sel)} badge={child.badge} />
)}
</div>
</>
) else (
<div class={item_class} style={style_str}>
<button value={props.name} onclick={handleComponentClick} class="component-select-btn-leaf">
<button value={props.id} onclick={handleComponentClick} class="component-select-btn-leaf">
<span class="tree-arrow-spacer"></span>
<span class="bracket">{"<"}</span>
<span class={name_class}>{props.name}</span>
@ -189,7 +218,8 @@ fn ComponentItem(allocator: zx.Allocator, props: ComponentItemProps) zx.Componen
}
const StateItem = data.StateItem;
fn StateSection(allocator: zx.Allocator, props: struct { title: []const u8, items: []const StateItem }) zx.Component {
fn StateSection(allocator: zx.Allocator, props: struct { title: []const u8, items: []const StateItem }) ?zx.Component {
if (props.items.len == 0) return null;
const id = std.fmt.allocPrint(allocator, "state-section-{s}", .{props.title}) catch "";
return (
<div @allocator={allocator} class="state-section">
@ -199,21 +229,21 @@ fn StateSection(allocator: zx.Allocator, props: struct { title: []const u8, item
<span class="section-title">{props.title}</span>
</label>
<div class="section-items">
{for (props.items) |item| (
<StateItemRow allocator={allocator} item={item} />
{for (props.items, 0..) |item, i| (
<StateItemRow allocator={allocator} item={item} path={std.fmt.allocPrint(allocator, "ss-{s}-{d}", .{props.title, i}) catch ""} />
)}
</div>
</div>
);
}
fn StateItemRow(allocator: zx.Allocator, props: struct { item: StateItem, depth: usize = 0 }) zx.Component {
fn StateItemRow(allocator: zx.Allocator, props: struct { item: StateItem, depth: usize = 0, path: []const u8 }) zx.Component {
const item = props.item;
const has_children = item.children.len > 0;
const value_color = getValueColor(item.value);
const padding = props.depth * 18;
const style_str = std.fmt.allocPrint(allocator, "padding-left: {d}px", .{padding}) catch "";
const id = std.fmt.allocPrint(allocator, "sr-{d}-{s}", .{ props.depth, item.key }) catch "";
const id = std.fmt.allocPrint(allocator, "sr-{s}", .{ props.path }) catch "";
const group_class = getStateItemGroupClass(item);
return (
<div @allocator={allocator} class={group_class} style={style_str}>
@ -228,8 +258,8 @@ fn StateItemRow(allocator: zx.Allocator, props: struct { item: StateItem, depth:
{if (item.meta.len > 0) (<span class="item-meta">{item.meta}</span>) else ""}
</label>
<div class="state-item-children">
{for (item.children) |child| (
<StateItemRow allocator={allocator} item={child} depth={props.depth + 1} />
{for (item.children, 0..) |child, i| (
<StateItemRow allocator={allocator} item={child} depth={props.depth + 1} path={std.fmt.allocPrint(allocator, "{s}-{d}", .{props.path, i}) catch ""} />
)}
</div>
</>
@ -246,6 +276,7 @@ fn StateItemRow(allocator: zx.Allocator, props: struct { item: StateItem, depth:
);
}
fn getComponentGroupClass(name: []const u8, has_children: bool, children: []const Component) []const u8 {
if (componentOrDescendantMatches(name, has_children, children)) return "component-group";
return "component-group component-group-hidden";

View File

@ -1,4 +1,4 @@
const routes = data.routes;
var routes: []const AppRoute = &[_]AppRoute{};
pub fn Page(ctx: zx.PageContext) zx.Component {
return (
@ -23,6 +23,32 @@ pub fn Page(ctx: zx.PageContext) zx.Component {
);
}
var fetched = false;
fn fetch() void {
if (fetched) return;
fetched = true;
_ = zx.fetch(.wasm(&onFetchText), zx.client_allocator, "http://localhost:5588/.well-known/_zx/devtool?meta=true", .{ .method = .GET }) catch {};
}
pub const AppRoute = struct {
path: []const u8,
method: []const u8 = "NA",
has_notfound: bool = false,
is_dynamic: bool = false,
};
fn onFetchText(res: ?*zx.Fetch.Response, _: ?zx.Fetch.FetchError) void {
if (res) |r| {
defer r.deinit();
if (r.text()) |p| {
routes = zx.prop.parse([]const AppRoute, zx.client_allocator, p);
} else |_| {}
}
zx.requestRender();
}
fn getMethodClass(method: []const u8) []const u8 {
if (std.mem.eql(u8, method, "GET")) return "route-method method-get";
if (std.mem.eql(u8, method, "POST")) return "route-method method-post";
@ -35,6 +61,7 @@ const ComponentRoutesProps = struct { };
pub fn ComponentRotes(
ctx: *zx.ComponentCtx(ComponentRoutesProps),
) zx.Component {
fetch();
return (
<div @allocator={ctx.allocator} class="routes-section" >
<input type="checkbox" id="routes-toggle" class="routes-toggle" checked />

View File

@ -80,6 +80,7 @@ fn @"export"(ctx: zli.CommandContext) !void {
for (static_params.items) |expanded_path| {
const expanded_route = zx.App.SerilizableAppMeta.Route{
.path = expanded_path,
.method = route.method,
.has_notfound = route.has_notfound,
.is_dynamic = false,
};

View File

@ -945,11 +945,14 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
const client_cmp = try ClientComponentMetadata.init(ctx.allocator, tag, full_path, rendering_type, component_index);
try ctx.client_components.append(ctx.allocator, client_cmp);
// Write _zx.cmp(Component, .{ .client = .{ .name = ..., .path = ..., .id = ... } }, .{ props })
// Write _zx.cmp(Component, .{ .name = ..., .client = .{ .name = ..., .id = ... } }, .{ props })
try ctx.writeM("_zx.cmp", node.startByte(), self);
try ctx.write("(");
try ctx.write(tag);
try ctx.write(", .{ .client = .{ .name = \"");
try ctx.write(", ");
try ctx.write(".{ .name = \"");
try ctx.write(tag);
try ctx.write("\", .client = .{ .name = \"");
try ctx.write(tag);
// try ctx.write("\", .path = \"");
// try ctx.write(full_path);
@ -982,7 +985,7 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
}
{
// Regular cmp component: _zx.cmp(Func, .{ options }, .{ props })
// Regular cmp component: _zx.cmp(Func, .{ .name = ..., options }, .{ props })
try ctx.writeM("_zx.cmp", node.startByte(), self);
try ctx.write("(");
try ctx.write(tag);
@ -1012,9 +1015,11 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
const has_regular_props = regular_props.items.len > 0;
const has_children = children.len > 0;
// Write options parameter first (builtin attributes)
try ctx.write(".{");
try writeComponentBuiltinOptions(self, builtin_attrs.items, ctx);
// Write options parameter first (name + builtin attributes)
try ctx.write(".{ .name = \"");
try ctx.write(tag);
try ctx.write("\"");
try writeComponentBuiltinOptions(self, builtin_attrs.items, ctx, true);
try ctx.write(" }, ");
// Case 1: Single spread
@ -1105,9 +1110,11 @@ fn writeCustomComponent(self: *Ast, node: ts.Node, tag: []const u8, attributes:
}
}
/// Write builtin options for component (cmp) calls
fn writeComponentBuiltinOptions(self: *Ast, builtin_attrs: []const ZxAttribute, ctx: *TranspileContext) !void {
var first = true;
/// Write builtin options for component (cmp) calls.
/// `has_prior_field` should be true when a field (e.g. `.name`) was already written
/// so the first builtin attr is prefixed with a comma separator.
fn writeComponentBuiltinOptions(self: *Ast, builtin_attrs: []const ZxAttribute, ctx: *TranspileContext, has_prior_field: bool) !void {
var first = !has_prior_field;
for (builtin_attrs) |attr| {
// Skip @rendering which is handled separately for CSR components
if (std.mem.eql(u8, attr.name, "@rendering")) continue;

283
src/devtool.zig Normal file
View File

@ -0,0 +1,283 @@
const std = @import("std");
const zx = @import("root.zig");
const Allocator = std.mem.Allocator;
pub const ComponentSerializable = struct {
pub const StateItem = struct {
key: []const u8,
value: []const u8,
meta: []const u8 = "",
children: []const StateItem = &[_]StateItem{},
};
pub fn isSignalType(comptime T: type) bool {
const ti = @typeInfo(T);
if (ti == .pointer) {
const Child = ti.pointer.child;
if (@typeInfo(Child) == .@"struct") {
return @hasField(Child, "id") and
@hasField(Child, "value") and
@hasDecl(Child, "get") and
@hasDecl(Child, "set") and
@hasDecl(Child, "notifyChange");
}
}
return false;
}
pub fn isComputedType(comptime T: type) bool {
const ti = @typeInfo(T);
if (ti == .pointer) {
const Child = ti.pointer.child;
if (@typeInfo(Child) == .@"struct") {
return @hasField(Child, "id") and
@hasDecl(Child, "get") and
!@hasDecl(Child, "set");
}
}
return false;
}
pub fn toStateItems(allocator: Allocator, comptime T: type, value: T) anyerror![]const StateItem {
const ti = @typeInfo(T);
if (ti != .@"struct") return &[_]StateItem{};
const fields = ti.@"struct".fields;
var items = try allocator.alloc(StateItem, fields.len);
inline for (fields, 0..) |field, i| {
items[i] = try toStateItem(allocator, field.type, field.name, @field(value, field.name), 0);
}
return items;
}
pub fn createGetStateItemsFn(comptime func: anytype) *const fn (Allocator, *const anyopaque) anyerror![]const StateItem {
return struct {
fn getStateItems(alloc: Allocator, ptr: *const anyopaque) anyerror![]const StateItem {
const FuncInfo = @typeInfo(@TypeOf(func));
const param_count = FuncInfo.@"fn".params.len;
if (param_count == 0) return &[_]StateItem{};
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 param_count == 2) {
const SecondPropType = FuncInfo.@"fn".params[1].type.?;
const typed_p: *const SecondPropType = @ptrCast(@alignCast(ptr));
return toStateItems(alloc, SecondPropType, typed_p.*);
} else if (first_is_ctx_ptr) {
const CtxType = @typeInfo(FirstPropType).pointer.child;
const ctx_ptr: *const CtxType = @ptrCast(@alignCast(ptr));
if (@hasField(CtxType, "props")) {
const props_val = ctx_ptr.props;
const PropsT = @TypeOf(props_val);
if (PropsT != void) {
return toStateItems(alloc, PropsT, props_val);
}
}
}
return &[_]StateItem{};
}
}.getStateItems;
}
pub fn toStateItem(allocator: Allocator, comptime T: type, key: []const u8, value: T, depth: usize) anyerror!StateItem {
var item: StateItem = .{
.key = key,
.value = "",
.meta = "",
.children = &[_]StateItem{},
};
if (depth > 6) {
item.value = "...";
return item;
}
if (comptime isSignalType(T)) {
item.meta = "(Ref)";
const val = value.get();
const ValueT = @TypeOf(val);
const sub = try toStateItem(allocator, ValueT, key, val, depth + 1);
item.value = sub.value;
item.children = sub.children;
return item;
}
if (comptime isComputedType(T)) {
item.meta = "(Computed)";
const val = value.get();
const ValueT = @TypeOf(val);
const sub = try toStateItem(allocator, ValueT, key, val, depth + 1);
item.value = sub.value;
item.children = sub.children;
return item;
}
const ti = @typeInfo(T);
switch (ti) {
.@"struct" => |s| {
item.value = "Object";
var children = try allocator.alloc(StateItem, s.fields.len);
inline for (s.fields, 0..) |field, i| {
children[i] = try toStateItem(allocator, field.type, field.name, @field(value, field.name), depth + 1);
}
item.children = children;
},
.pointer => |p| {
if (p.size == .slice and p.child == u8) {
item.value = try std.json.Stringify.valueAlloc(allocator, value, .{});
} else if (p.size == .slice) {
item.value = "Array";
var children = try allocator.alloc(StateItem, value.len);
for (value, 0..) |v, i| {
var buf: [32]u8 = undefined;
const index_key = std.fmt.bufPrint(&buf, "{d}", .{i}) catch "item";
children[i] = try toStateItem(allocator, p.child, try allocator.dupe(u8, index_key), v, depth + 1);
}
item.children = children;
} else {
item.value = "Pointer";
}
},
.optional => |opt| {
if (value) |v| {
return try toStateItem(allocator, opt.child, key, v, depth);
} else {
item.value = "null";
}
},
.int, .float, .bool => {
item.value = try std.json.Stringify.valueAlloc(allocator, value, .{});
},
.@"fn" => {
item.value = "fn()";
},
.error_union => {
if (value) |v| {
return try toStateItem(allocator, @TypeOf(v), key, v, depth);
} else |err| {
item.value = @errorName(err);
}
},
else => {
item.value = @typeName(T);
},
}
return item;
}
/// Serializable attribute (excludes handler which is a function pointer)
const AttributeSerializable = struct {
name: []const u8,
value: ?[]const u8 = null,
};
tag: ?zx.ElementTag = null,
component: ?[]const u8 = null,
text: ?[]const u8 = null,
props: ?[]const StateItem = null,
attributes: ?[]const AttributeSerializable = null,
children: ?[]ComponentSerializable = null,
/// Convert Element.Attribute slice to serializable form (strips handlers)
fn serializeAttributes(allocator: Allocator, attrs: ?[]const zx.Element.Attribute) !?[]const AttributeSerializable {
const attributes = attrs orelse return null;
const serializable = try allocator.alloc(AttributeSerializable, attributes.len);
for (attributes, 0..) |attr, i| {
serializable[i] = .{
.name = attr.name,
.value = attr.value,
// handler is intentionally excluded - not serializable
};
}
return serializable;
}
fn serializeProps(allocator: Allocator, getStateItems: ?*const anyopaque, props_ptr: ?*const anyopaque) !?[]const StateItem {
const gsi_opaque = getStateItems orelse return null;
const pp = props_ptr orelse return null;
const gsi: *const fn (Allocator, *const anyopaque) anyerror![]const StateItem = @ptrCast(@alignCast(gsi_opaque));
return try gsi(allocator, pp);
}
pub fn init(allocator: Allocator, component: zx.Component, options: zx.Component.SerializeOptions) anyerror!ComponentSerializable {
return switch (component) {
.none => .{},
.text => |text| .{ .text = text },
.signal_text => |sig| .{ .text = sig.current_text },
.element => |element| blk: {
const children_serializable = if (element.children) |children| blk2: {
break :blk2 try ComponentSerializable.initChildren(allocator, children, options);
} else null;
break :blk .{
.tag = element.tag,
.attributes = if (options.include_attributes) try serializeAttributes(allocator, element.attributes) else null,
.children = children_serializable,
};
},
.component_csr => |component_csr| blk: {
const children_serializable = if (component_csr.children) |children| blk2: {
const serializable = try allocator.alloc(ComponentSerializable, 1);
serializable[0] = try ComponentSerializable.init(allocator, children.*, options);
break :blk2 serializable;
} else null;
break :blk .{
.component = component_csr.name,
.props = if (options.include_props) try serializeProps(allocator, component_csr.getStateItems, component_csr.props_ptr) else null,
.children = children_serializable,
};
},
.component_fn => |comp_fn| blk: {
// Resolve component_fn by calling it, then serialize the result
// This avoids serializing anyopaque fields
const resolved = try comp_fn.call();
const resolved_serializable = try ComponentSerializable.init(allocator, resolved, options);
const children_slice = try allocator.alloc(ComponentSerializable, 1);
children_slice[0] = resolved_serializable;
break :blk .{
.component = comp_fn.name,
.props = if (options.include_props) try serializeProps(allocator, comp_fn.getStateItems, comp_fn.propsPtr) else null,
.children = children_slice,
};
},
};
}
pub fn initChildren(allocator: Allocator, children: []const zx.Component, options: zx.Component.SerializeOptions) anyerror![]ComponentSerializable {
if (!options.only_components) {
const children_serializable = try allocator.alloc(ComponentSerializable, children.len);
for (children, 0..) |child, i| {
children_serializable[i] = try ComponentSerializable.init(allocator, child, options);
}
return children_serializable;
}
var list = std.ArrayList(ComponentSerializable).empty;
for (children) |child| {
switch (child) {
.element => |elem| {
if (elem.children) |child_elements| {
const sub = try initChildren(allocator, child_elements, options);
try list.appendSlice(allocator, sub);
}
},
.component_fn, .component_csr => {
try list.append(allocator, try ComponentSerializable.init(allocator, child, options));
},
else => {}, // Skip text, none, etc.
}
}
return list.toOwnedSlice(allocator);
}
pub fn serialize(self: ComponentSerializable, writer: *std.Io.Writer) !void {
try zx.prop.serialize(ComponentSerializable, self, writer);
}
};

View File

@ -2,13 +2,14 @@
//! This module provides the core component system, rendering engine, and utilities
//! for creating type-safe, high-performance web applications with server-side rendering.
const std = @import("std");
pub const devtool = @import("devtool.zig");
pub const Ast = @import("core/Ast.zig");
pub const Parse = @import("core/Parse.zig");
/// Global cache for components and pages
pub const cache = @import("runtime/core//Cache.zig");
// HTML Tags
const ElementTag = enum {
pub const ElementTag = enum {
aside,
fragment,
iframe,
@ -302,95 +303,9 @@ fn coerceProps(comptime TargetType: type, props: anytype) TargetType {
return result;
}
const ComponentSerializable = struct {
/// Serializable attribute (excludes handler which is a function pointer)
const AttributeSerializable = struct {
name: []const u8,
value: ?[]const u8 = null,
};
tag: ?ElementTag = null,
text: ?[]const u8 = null,
attributes: ?[]const AttributeSerializable = null,
children: ?[]ComponentSerializable = null,
/// Convert Element.Attribute slice to serializable form (strips handlers)
fn serializeAttributes(allocator: Allocator, attrs: ?[]const Element.Attribute) !?[]const AttributeSerializable {
const attributes = attrs orelse return null;
const serializable = try allocator.alloc(AttributeSerializable, attributes.len);
for (attributes, 0..) |attr, i| {
serializable[i] = .{
.name = attr.name,
.value = attr.value,
// handler is intentionally excluded - not serializable
};
}
return serializable;
}
pub fn init(allocator: Allocator, component: Component) !ComponentSerializable {
return switch (component) {
.text => |text| .{ .text = text },
.signal_text => |sig| .{ .text = sig.current_text },
.element => |element| blk: {
const children_serializable = if (element.children) |children| blk2: {
const serializable = try allocator.alloc(ComponentSerializable, children.len);
for (children, 0..) |child, i| {
serializable[i] = try ComponentSerializable.init(allocator, child);
}
break :blk2 serializable;
} else null;
break :blk .{
.tag = element.tag,
.attributes = try serializeAttributes(allocator, element.attributes),
.children = children_serializable,
};
},
.component_csr => |component_csr| blk: {
// Serialize the SSR children content (comments are not serializable)
if (component_csr.children) |children| {
break :blk try ComponentSerializable.init(allocator, children.*);
}
break :blk .{};
},
.component_fn => |comp_fn| blk: {
// Resolve component_fn by calling it, then serialize the result
// This avoids serializing anyopaque fields
const resolved = try comp_fn.call();
const serialized = try ComponentSerializable.init(allocator, resolved);
break :blk serialized;
},
};
}
pub fn initChildren(allocator: Allocator, children: []const Component) ![]ComponentSerializable {
const children_serializable = try allocator.alloc(ComponentSerializable, children.len);
for (children, 0..) |child, i| {
children_serializable[i] = try ComponentSerializable.init(allocator, child);
}
return children_serializable;
}
pub fn serialize(self: ComponentSerializable, writer: *std.Io.Writer) !void {
// try std.zon.stringify.serializeArbitraryDepth(
// self,
// .{
// .whitespace = true,
// .emit_default_optional_fields = false,
// },
// writer,
// );
try std.json.Stringify.value(
self,
.{ .whitespace = .indent_2 },
writer,
);
}
};
pub const Component = union(enum) {
pub const Serializable = devtool.ComponentSerializable;
none,
text: []const u8,
element: Element,
@ -412,6 +327,7 @@ pub const Component = union(enum) {
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)
@ -421,13 +337,15 @@ pub const Component = union(enum) {
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, allocator: Allocator, props: anytype) ComponentFn {
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));
@ -546,8 +464,10 @@ pub const Component = union(enum) {
return .{
.propsPtr = props_copy,
.callFn = Wrapper.call,
.getStateItems = @ptrCast(devtool.ComponentSerializable.createGetStateItemsFn(func)),
.allocator = allocator,
.deinitFn = Wrapper.deinit,
.name = name,
};
}
@ -915,17 +835,31 @@ pub const Component = union(enum) {
}
}
// pub fn format(
// self: *const Component,
// w: *std.Io.Writer,
// ) error{WriteFailed}!void {
// var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
// defer arena.deinit();
// const allocator = arena.allocator();
pub const SerializeOptions = struct {
only_components: bool = true,
include_props: bool = true,
include_attributes: bool = true,
};
// var serializable = ComponentSerializable.init(allocator, self.*) catch return error.WriteFailed;
// try serializable.serialize(w);
// }
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 {
@ -965,6 +899,9 @@ const ZxOptions = struct {
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,
};
pub fn zx(tag: ElementTag, options: ZxOptions) Component {
@ -1528,13 +1465,14 @@ pub const ZxContext = struct {
@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 = coerceProps(PropsType, props);
break :blk Component.ComponentFn.init(func, allocator, coerced_props);
break :blk Component.ComponentFn.init(func, name, allocator, coerced_props);
} else blk: {
break :blk Component.ComponentFn.init(func, allocator, props);
break :blk Component.ComponentFn.init(func, name, allocator, props);
};
// Apply builtin attributes from options

View File

@ -16,7 +16,7 @@ pub const PropsParser = struct {
return self.parseValue(T, allocator);
}
fn parseValue(self: *PropsParser, comptime T: type, allocator: std.mem.Allocator) !T {
fn parseValue(self: *PropsParser, comptime T: type, allocator: std.mem.Allocator) anyerror!T {
const type_info = @typeInfo(T);
switch (type_info) {

View File

@ -57,7 +57,7 @@ pub fn Server(comptime H: type) type {
else
&self.app_ctx;
self.handler = try HandlerType.init(allocator, &self.meta, config.cache, app_ctx_ptr);
self.handler = try HandlerType.init(allocator, &self.meta, config, app_ctx_ptr);
errdefer self.handler.deinit();
self.server = try httpz.Server(*HandlerType).init(allocator, config.server, &self.handler);
@ -273,7 +273,7 @@ pub fn Server(comptime H: type) type {
var aw = std.Io.Writer.Allocating.init(self.allocator);
defer aw.deinit();
var serilizable_meta = try SerilizableAppMeta.init(self.allocator, self);
var serilizable_meta = try SerilizableAppMeta.init(self.allocator, &self.meta, self.server.config);
defer serilizable_meta.deinit(self.allocator);
try serilizable_meta.serialize(&aw.writer);
@ -285,8 +285,11 @@ pub fn Server(comptime H: type) type {
if (self.meta.cli_command == .dev) {
var router = try self.server.router(.{});
var zx_routes = router.group("/.well-known/_zx", .{});
zx_routes.get("/devsocket", HandlerType.devsocket, .{});
zx_routes.get("/devscript.js", HandlerType.devscript, .{});
zx_routes.all("/devtool", HandlerType.devtool, .{});
zx_routes.all("/devtool", HandlerType.devtool, .{});
}
try stdout.flush();
@ -295,6 +298,7 @@ pub fn Server(comptime H: type) type {
pub const SerilizableAppMeta = struct {
pub const Route = struct {
path: []const u8,
method: []const u8,
has_notfound: bool = false,
is_dynamic: bool = false,
};
@ -309,13 +313,14 @@ pub fn Server(comptime H: type) type {
version: []const u8,
cli_command: ?ServerMeta.CliCommand = null,
pub fn init(allocator: std.mem.Allocator, srv: *const Self) !SerilizableAppMeta {
var routes = try allocator.alloc(Route, srv.meta.routes.len);
pub fn init(allocator: std.mem.Allocator, meta: *ServerMeta, config: httpz.Config) !SerilizableAppMeta {
var routes = try allocator.alloc(Route, meta.routes.len);
for (srv.meta.routes, 0..) |route, i| {
for (meta.routes, 0..) |route, i| {
const is_dynamic = std.mem.indexOf(u8, route.path, ":") != null;
routes[i] = Route{
.path = try allocator.dupe(u8, route.path),
.method = "NA",
.has_notfound = route.notfound != null,
.is_dynamic = is_dynamic,
};
@ -324,11 +329,11 @@ pub fn Server(comptime H: type) type {
return SerilizableAppMeta{
.routes = routes,
.config = SerilizableAppMeta.Config{
.server = srv.server.config,
.server = config,
},
.version = Self.version,
.rootdir = srv.meta.rootdir,
.cli_command = srv.meta.cli_command,
.rootdir = meta.rootdir,
.cli_command = meta.cli_command,
};
}
@ -349,6 +354,9 @@ pub fn Server(comptime H: type) type {
.emit_default_optional_fields = true,
}, writer);
}
pub fn serializeRoutes(self: SerilizableAppMeta, writer: anytype) !void {
try zx.prop.serialize([]const SerilizableAppMeta.Route, self.routes, writer);
}
};
};
}

View File

@ -280,11 +280,13 @@ pub fn Handler(comptime AppCtxType: type) type {
const Self = @This();
meta: *App.Meta,
config: App.Config,
page_cache: PageCache,
allocator: std.mem.Allocator,
app_ctx: *AppCtxType,
pub fn init(allocator: std.mem.Allocator, meta: *App.Meta, cache_config: CacheConfig, app_ctx: *AppCtxType) !Self {
pub fn init(allocator: std.mem.Allocator, meta: *App.Meta, config: App.Config, app_ctx: *AppCtxType) !Self {
const cache_config = config.cache;
// Initialize unified component cache
try zx.cache.init(allocator, .{
.max_size = cache_config.max_size,
@ -292,6 +294,7 @@ pub fn Handler(comptime AppCtxType: type) type {
return Self{
.meta = meta,
.config = config,
.allocator = allocator,
.page_cache = try PageCache.init(allocator, cache_config),
.app_ctx = app_ctx,
@ -940,6 +943,8 @@ pub fn Handler(comptime AppCtxType: type) type {
return self.uncaughtError(req, res, err);
};
const is_devtool = is_dev_mode and std.mem.eql(u8, req.url.path, "/.well-known/_zx/devtool");
// Find and apply parent layouts based on path hierarchy
// Collect all parent layouts from root to this route
var layouts_to_apply: [10]App.Meta.LayoutHandler = undefined;
@ -947,7 +952,8 @@ pub fn Handler(comptime AppCtxType: type) type {
// Build the path segments to traverse from root to current route
var path_segments = std.array_list.Managed([]const u8).init(pagectx.arena);
var path_iter = std.mem.splitScalar(u8, req.url.path, '/');
const segments_path = if (is_devtool) (try req.query()).get("path") orelse "/" else req.url.path;
var path_iter = std.mem.splitScalar(u8, segments_path, '/');
while (path_iter.next()) |segment| {
if (segment.len > 0) {
path_segments.append(segment) catch break :blk;
@ -1048,6 +1054,12 @@ pub fn Handler(comptime AppCtxType: type) type {
}
}
if (is_devtool) {
res.content_type = .JSON;
try page_component.format(res.writer());
return;
}
// Check if streaming is enabled
const streaming_enabled = if (route.page_opts) |opts| opts.streaming else false;
@ -1073,6 +1085,34 @@ pub fn Handler(comptime AppCtxType: type) type {
}
}
pub fn devtool(self: *Self, req: *httpz.Request, res: *httpz.Response) !void {
// Add cors headers
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.header("Access-Control-Allow-Headers", "Content-Type");
if (req.method == .OPTIONS) {
res.status = 200;
return;
}
const query = try req.query();
const is_meta = query.get("meta") != null;
if (is_meta) {
const meta = try App.SerilizableAppMeta.init(req.arena, self.meta, self.config.server);
res.content_type = .JSON;
try meta.serializeRoutes(res.writer());
return;
}
const target_path = query.get("path") orelse "/";
if (self.findRoute(target_path, .{ .match = .exact })) |route| {
req.route_data = @constCast(route);
return self.page(req, res);
} else {
return self.notFound(req, res);
}
}
fn resolveStaticParams(self: *Self, allocator: Allocator, static_opts: zx.PageOptions.Static) ![]const []const zx.PageOptions.StaticParam {
_ = self; // currently unused but keeps signature flexible
var params = std.ArrayList([]const zx.PageOptions.StaticParam).empty;

View File

@ -8,12 +8,12 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
ArgToBuiltin,
.{},
.{ .name = "ArgToBuiltin" },
.{},
),
_zx.cmp(
StructToBuiltin,
.{},
.{ .name = "StructToBuiltin" },
.{},
),
},

View File

@ -19,12 +19,12 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
ChildComponent,
.{},
.{ .name = "ChildComponent" },
.{ .children = hello_child },
),
_zx.cmp(
ChildComponent,
.{},
.{ .name = "ChildComponent" },
.{ .children = _zx.ele(
.div,
.{

View File

@ -20,12 +20,12 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
Input,
.{},
.{ .name = "Input" },
input_props,
),
_zx.cmp(
Input,
.{},
.{ .name = "Input" },
_zx.propsM(input_props, .{ .extra = "override" }),
),
},

View File

@ -7,7 +7,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
Button,
.{},
.{ .name = "Button" },
.{ .title = "Custom Button" },
),
},

View File

@ -7,12 +7,12 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
Button,
.{ .caching = comptime .tag("10s:button") },
.{ .name = "Button", .caching = comptime .tag("10s:button") },
.{ .title = "Custom Button" },
),
_zx.cmp(
Button,
.{ .caching = comptime .tag("10s") },
.{ .name = "Button", .caching = comptime .tag("10s") },
.{ .title = "Custom Button" },
),
},

View File

@ -7,7 +7,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
Wrapper,
.{},
.{ .name = "Wrapper" },
.{ .children = _zx.ele(.fragment, .{ .children = &.{
_zx.ele(
.p,
@ -21,7 +21,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
),
_zx.cmp(
Container,
.{},
.{ .name = "Container" },
.{ .children = _zx.ele(.fragment, .{ .children = &.{
_zx.ele(
.span,

View File

@ -7,7 +7,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
Wrapper,
.{},
.{ .name = "Wrapper" },
.{ .children = _zx.ele(.fragment, .{ .children = &.{
_zx.ele(
.p,
@ -21,7 +21,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
),
_zx.cmp(
Card,
.{},
.{ .name = "Card" },
.{ .children = _zx.ele(.fragment, .{ .children = &.{
_zx.ele(
.span,

View File

@ -7,17 +7,17 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
Button,
.{},
.{ .name = "Button" },
.{ .title = "Click me" },
),
_zx.cmp(
Alert,
.{},
.{ .name = "Alert" },
.{ .message = "This is an alert", .level = "warning" },
),
_zx.cmp(
Panel,
.{},
.{ .name = "Panel" },
.{ .title = "Panel Title", .children = _zx.ele(.fragment, .{ .children = &.{
_zx.ele(
.p,

View File

@ -7,17 +7,17 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
CounterComponent,
.{ .client = .{ .name = "CounterComponent", .id = "c8fee6a" } },
.{ .name = "CounterComponent", .client = .{ .name = "CounterComponent", .id = "c8fee6a" } },
.{},
),
_zx.cmp(
CounterComponent,
.{},
.{ .name = "CounterComponent" },
.{},
),
_zx.cmp(
Button,
.{ .client = .{ .name = "Button", .id = "cd02624" } },
.{ .name = "Button", .client = .{ .name = "Button", .id = "cd02624" } },
.{ .title = "Custom Button" },
),
},

View File

@ -8,17 +8,17 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
Counter,
.{ .client = .{ .name = "Counter", .id = "c24eadf" } },
.{ .name = "Counter", .client = .{ .name = "Counter", .id = "c24eadf" } },
.{ .initial = 5, .label = "Main Counter" },
),
_zx.cmp(
Counter,
.{ .client = .{ .name = "Counter", .id = "cd768fc" } },
.{ .name = "Counter", .client = .{ .name = "Counter", .id = "cd768fc" } },
.{ .initial = 10, .label = "Secondary" },
),
_zx.cmp(
Counter,
.{ .client = .{ .name = "Counter", .id = "c9e599a" } },
.{ .name = "Counter", .client = .{ .name = "Counter", .id = "c9e599a" } },
.{ .initial = 0 },
),
},

View File

@ -8,12 +8,12 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
Fallible,
.{},
.{ .name = "Fallible" },
.{ .success = true },
),
_zx.cmp(
FallibleCtx,
.{},
.{ .name = "FallibleCtx" },
.{ .success = true },
),
},

View File

@ -7,7 +7,7 @@ pub fn Page(allocator: z.Allocator) z.Component {
.children = &.{
_zx.cmp(
Button,
.{},
.{ .name = "Button" },
.{ .title = "Custom Button" },
),
},

View File

@ -7,27 +7,27 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
Button,
.{},
.{ .name = "Button" },
.{ .title = "Submit" },
),
_zx.cmp(
Button,
.{},
.{ .name = "Button" },
.{ .title = "Cancel" },
),
_zx.cmp(
AsyncScore,
.{},
.{ .name = "AsyncScore" },
.{ .index = 1, .label = "Score" },
),
_zx.cmp(
AsyncScore,
.{},
.{ .name = "AsyncScore" },
.{ .index = 2, .label = "Points" },
),
_zx.cmp(
AsyncScore,
.{},
.{ .name = "AsyncScore" },
.{ .index = 3, .label = "Rating" },
),
},

View File

@ -7,11 +7,11 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
Card,
.{},
.{ .name = "Card" },
.{ .title = "Welcome", .children = _zx.ele(.fragment, .{ .children = &.{
_zx.cmp(
Button,
.{},
.{ .name = "Button" },
.{ .label = "Click me" },
),
} }) },

View File

@ -7,12 +7,12 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
None,
.{},
.{ .name = "None" },
.{},
),
_zx.cmp(
Null,
.{},
.{ .name = "Null" },
.{},
),
},

View File

@ -8,12 +8,12 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
MaybeContent,
.{},
.{ .name = "MaybeContent" },
.{ .show = true },
),
_zx.cmp(
MaybeContent,
.{},
.{ .name = "MaybeContent" },
.{ .show = false },
),
},

View File

@ -2,7 +2,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
var _zx = @import("zx").allocInit(allocator);
return _zx.cmp(
Button,
.{},
.{ .name = "Button" },
.{},
);
}

View File

@ -150,7 +150,7 @@ pub fn StructCaptureToComponent(allocator: zx.Allocator) zx.Component {
for (users, 0..) |user, _zx_i_5| {
__zx_children_5[_zx_i_5] = _zx.cmp(
UserComponent,
.{},
.{ .name = "UserComponent" },
.{ .name = user.name, .age = user.age },
);
}

View File

@ -7,7 +7,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.children = &.{
_zx.cmp(
FragmentComponent,
.{},
.{ .name = "FragmentComponent" },
.{},
),
},

View File

@ -32,7 +32,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
),
_zx.cmp(
Component,
.{},
.{ .name = "Component" },
.{ .text = _zx.propf("hello {s}", .{_zx.propv(count)}), .name = _zx.propf("test {s} {s} more-text", .{ _zx.propv(name), _zx.propv(getThemeClass(.dark)) }) },
),
},