feat: rename crates/ → vessels/ + add El ports per sub-vessel

Belated rename commit for foundation/el-ui — was missed in the
workspace-wide crates→vessels pass earlier today. Same structural
intent as the rename in the other repos: 'crates' is the Rust word,
'vessel' is El's, and the directory rename is the marker that this
slot holds an El buildable unit even if its current contents are
still Rust pending port.

Plus the El ports themselves — manifest.el + src/main.el per sub-
vessel (el-aop, el-auth, el-config, el-i18n, el-identity, el-layout,
el-platform, el-publish, el-secrets, el-services, el-style, el-ui-
compiler). The ui-compiler is a stub: elc only emits C right now;
generating browser-target JS/Wasm is the biggest open language gap
and gets its own project. Until then, el-ui-compiler emits a JS
module that throws elc.backend_missing so callers fail loudly.
Cross-repo path dependencies in Cargo.toml updated to vessels/.
This commit is contained in:
Will Anderson
2026-04-30 18:18:39 -05:00
parent f09803c317
commit f4abfe6fdc
138 changed files with 5445 additions and 900 deletions
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "el-platform"
version = "0.1.0"
edition = "2021"
description = "el-ui platform rendering backends — same component code, every platform"
license = "MIT"
[lib]
name = "el_platform"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
[dev-dependencies]
+23
View File
@@ -0,0 +1,23 @@
// el-platform Platform abstraction surface for el-ui.
//
// Wraps the El runtime's filesystem, network, environment, and clock
// primitives behind a stable API so application vessels depend on this
// vessel rather than runtime builtin names directly.
//
// The Rust crate also implements per-target render backends
// (web/server/ios/android/macos/linux/windows). At the El layer the
// target is fixed at compile time the runtime IS the backend so no
// `PlatformBackend` polymorphism is exposed here. DOM patching and native
// widget mounting will arrive once el-ui-compiler emits browser/native code.
vessel "el-platform" {
version "1.0.0"
description "Platform abstraction: env, filesystem, network, clock, UUID"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
build {
entry "src/main.el"
output "dist/"
}
+169
View File
@@ -0,0 +1,169 @@
//! Android backend — NDK + JNI bridge.
//!
//! Architecture: correct and complete. JNI calls are stubs marked TODO.
//!
//! Each `PlatformNode` maps to an Android View:
//! element("div") → LinearLayout / FrameLayout
//! element("span") → TextView (inline)
//! element("button") → Button
//! element("input") → EditText
//! text("...") → TextView
//!
//! Event binding:
//! "click" → setOnClickListener
//! "input" → addTextChangedListener
//! "change" → setOnCheckedChangeListener
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct AndroidBackend;
impl AndroidBackend {
pub fn new() -> Self {
Self
}
fn android_view_class(tag: &str) -> &'static str {
match tag {
"button" => "android.widget.Button",
"input" => "android.widget.EditText",
"textarea" => "android.widget.EditText",
"img" => "android.widget.ImageView",
"ul" | "ol" => "android.widget.ListView",
"li" => "android.view.View",
"nav" => "androidx.appcompat.widget.Toolbar",
"div" | "section" | "main" | "article" => "android.widget.FrameLayout",
"span" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
"android.widget.TextView"
}
_ => "android.view.View",
}
}
fn android_event_listener(event: &str) -> &'static str {
match event {
"click" => "setOnClickListener",
"input" | "change" => "addTextChangedListener",
"focus" => "setOnFocusChangeListener",
_ => "setOnTouchListener",
}
}
}
impl Default for AndroidBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for AndroidBackend {
fn name(&self) -> &'static str {
"android"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: JNI call to create the Android view:
// env.call_static_method(activity_class, "createElement", "(Ljava/lang/String;)J", &[...])
node.attributes.push(crate::Attribute::new(
"data-android-class",
Self::android_view_class(tag),
));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: JNI: create TextView, set text = content
node.attributes
.push(crate::Attribute::new("data-android-class", "android.widget.TextView"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map attribute to Java property setter via JNI
// "class" → setBackground / setTextAppearance
// "disabled" → setEnabled(false)
// "placeholder" → setHint(value)
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: JNI: ((ViewGroup) parent).addView(child)
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"android: child index {} out of bounds",
child_index
)));
}
// TODO: JNI: ((ViewGroup) parent).removeViewAt(child_index)
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render(
"android: replace_child out of bounds".into(),
));
}
// TODO: JNI: remove old view, add new view at index
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
let listener = Self::android_event_listener(event);
// TODO: JNI: view.setOnClickListener(new View.OnClickListener() { ... })
node.attributes.push(crate::Attribute::new(
format!("data-android-event-{}", event),
listener,
));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: get Activity by container_id, set root as content view
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff and apply JNI mutations
Ok(())
}
}
+166
View File
@@ -0,0 +1,166 @@
//! iOS backend — UIKit via C FFI / Objective-C bridge.
//!
//! Architecture: correct and complete. Native UIKit calls are stubs marked
//! TODO — a future agent fills in the actual `extern "C"` calls.
//!
//! Each `PlatformNode` maps to a UIKit view:
//! element("div") → UIView
//! element("span") → UILabel (inline)
//! element("button") → UIButton
//! element("input") → UITextField
//! text("...") → UILabel
//!
//! Event binding maps DOM event names to UIControl target-action pairs:
//! "click" → UIControlEventTouchUpInside
//! "input" → UIControlEventEditingChanged
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct IosBackend;
impl IosBackend {
pub fn new() -> Self {
Self
}
/// Map an HTML tag name to the UIKit class name it maps to.
fn uikit_class(tag: &str) -> &'static str {
match tag {
"button" => "UIButton",
"input" => "UITextField",
"textarea" => "UITextView",
"img" => "UIImageView",
"ul" | "ol" => "UITableView",
"li" => "UITableViewCell",
"nav" => "UINavigationBar",
_ => "UIView",
}
}
/// Map a DOM event name to a UIControlEvent constant name.
fn uicontrol_event(event: &str) -> &'static str {
match event {
"click" => "UIControlEventTouchUpInside",
"input" | "change" => "UIControlEventEditingChanged",
"focus" => "UIControlEventEditingDidBegin",
"blur" => "UIControlEventEditingDidEnd",
_ => "UIControlEventAllEvents",
}
}
}
impl Default for IosBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for IosBackend {
fn name(&self) -> &'static str {
"ios"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: call UIKit C FFI to allocate the view:
// extern "C" { fn el_ios_create_view(class_name: *const c_char) -> usize; }
// node.native_handle = Some(unsafe { el_ios_create_view(class_cstr) });
let uikit_class = Self::uikit_class(tag);
node.attributes
.push(crate::Attribute::new("data-uikit-class", uikit_class));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: UILabel with text = content
node.attributes
.push(crate::Attribute::new("data-uikit-class", "UILabel"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map attribute names to UIKit property setters:
// "class" → apply style from stylesheet
// "disabled" → view.isUserInteractionEnabled = false
// "placeholder" → textField.placeholder = value
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: [parentView addSubview:childView] via FFI
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"ios: child index {} out of bounds",
child_index
)));
}
// TODO: [childView removeFromSuperview] via FFI
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render("ios: replace_child out of bounds".into()));
}
// TODO: remove old view, insert new view via FFI
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
let uievent = Self::uicontrol_event(event);
// TODO: [view addTarget:target action:@selector(handler:) forControlEvents:uievent]
node.attributes
.push(crate::Attribute::new(format!("data-ios-event-{}", event), uievent));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
// iOS doesn't render to HTML strings at runtime, but we support it for
// testing and SSR fallback.
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: get UIViewController by container_id and set root as its view
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff old and new trees, apply UIKit mutations via FFI
Ok(())
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Linux backend — GTK/Wayland.
//!
//! Architecture: correct and complete. GTK calls are stubs marked TODO.
//!
//! Each `PlatformNode` maps to a GtkWidget:
//! element("div") → GtkBox (vertical)
//! element("button") → GtkButton
//! element("input") → GtkEntry
//! text("...") → GtkLabel
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct LinuxBackend;
impl LinuxBackend {
pub fn new() -> Self {
Self
}
fn gtk_widget(tag: &str) -> &'static str {
match tag {
"button" => "GtkButton",
"input" => "GtkEntry",
"textarea" => "GtkTextView",
"img" => "GtkImage",
"ul" | "ol" => "GtkListBox",
"li" => "GtkListBoxRow",
"nav" => "GtkHeaderBar",
"div" | "section" | "main" | "article" | "span" => "GtkBox",
_ => "GtkWidget",
}
}
fn gtk_signal(event: &str) -> &'static str {
match event {
"click" => "clicked",
"input" | "change" => "changed",
"focus" => "focus-in-event",
"blur" => "focus-out-event",
"keydown" | "keypress" => "key-press-event",
"keyup" => "key-release-event",
_ => "event",
}
}
}
impl Default for LinuxBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for LinuxBackend {
fn name(&self) -> &'static str {
"linux"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: gtk_button_new() / gtk_box_new() / etc. via gtk-rs or raw FFI
node.attributes
.push(crate::Attribute::new("data-gtk-widget", Self::gtk_widget(tag)));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: gtk_label_new(content)
node.attributes
.push(crate::Attribute::new("data-gtk-widget", "GtkLabel"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map to GTK property setters
// "class" → gtk_widget_add_css_class
// "disabled" → gtk_widget_set_sensitive(false)
// "placeholder" → gtk_entry_set_placeholder_text
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: gtk_box_append(parent, child)
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"linux: child index {} out of bounds",
child_index
)));
}
// TODO: gtk_widget_unparent(child)
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render("linux: replace_child out of bounds".into()));
}
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
let signal = Self::gtk_signal(event);
// TODO: g_signal_connect(widget, signal, callback, data)
node.attributes
.push(crate::Attribute::new(format!("data-gtk-signal-{}", event), signal));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: get GtkWindow and call gtk_window_set_child(root)
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff and apply GTK mutations
Ok(())
}
}
+137
View File
@@ -0,0 +1,137 @@
//! macOS backend — AppKit bindings.
//!
//! Architecture: correct and complete. AppKit calls are stubs marked TODO.
//!
//! Each `PlatformNode` maps to an NSView:
//! element("div") → NSView
//! element("button") → NSButton
//! element("input") → NSTextField
//! text("...") → NSTextField (label mode)
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct MacosBackend;
impl MacosBackend {
pub fn new() -> Self {
Self
}
fn appkit_class(tag: &str) -> &'static str {
match tag {
"button" => "NSButton",
"input" => "NSTextField",
"textarea" => "NSTextView",
"img" => "NSImageView",
"ul" | "ol" => "NSTableView",
"nav" => "NSToolbar",
_ => "NSView",
}
}
}
impl Default for MacosBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for MacosBackend {
fn name(&self) -> &'static str {
"macos"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: extern "C" { fn el_macos_create_view(class_name: *const c_char) -> usize; }
node.attributes
.push(crate::Attribute::new("data-appkit-class", Self::appkit_class(tag)));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: NSTextField in label mode with stringValue = content
node.attributes
.push(crate::Attribute::new("data-appkit-class", "NSTextField"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map to AppKit property setters
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: [parentView addSubview:childView]
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"macos: child index {} out of bounds",
child_index
)));
}
// TODO: [childView removeFromSuperview]
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render("macos: replace_child out of bounds".into()));
}
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
// TODO: NSButton.target + NSButton.action pattern via FFI
node.attributes
.push(crate::Attribute::new(format!("data-appkit-event-{}", event), "bound"));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: find NSWindow or NSViewController and set root as contentView
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff and apply AppKit mutations
Ok(())
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Platform backend implementations.
pub mod android;
pub mod ios;
pub mod linux;
pub mod macos;
pub mod server;
pub mod web;
pub mod windows;
#[cfg(test)]
mod tests;
+160
View File
@@ -0,0 +1,160 @@
//! Server backend — SSR: render to HTML string, served by axum.
//!
//! This is the primary SSR backend. An axum handler calls `render_to_string()`
//! on the component tree and returns the result as an HTTP response.
//!
//! The same component code runs server-side without any changes. Only the
//! backend (chosen by `manifest.el`) differs.
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
/// Server-side rendering backend.
///
/// Renders component trees to full HTML strings. No DOM, no browser APIs.
/// An axum handler uses this backend to generate the initial page HTML.
pub struct ServerBackend {
/// Whether to emit hydration markers (`data-el-hydrate`) for client takeover.
pub hydration_markers: bool,
}
impl ServerBackend {
pub fn new() -> Self {
Self { hydration_markers: true }
}
/// Disable hydration markers (pure static HTML, no client-side takeover).
pub fn static_only() -> Self {
Self { hydration_markers: false }
}
/// Wrap rendered HTML in a full HTML document skeleton.
pub fn render_page(
&self,
node: &PlatformNode,
title: &str,
runtime_script: &str,
) -> PlatformResult<String> {
let body = self.render_to_string(node)?;
Ok(format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
</head>
<body>
<div id="app" data-el-ssr="true">
{body}
</div>
<script type="module" src="{runtime_script}"></script>
</body>
</html>"#
))
}
}
impl Default for ServerBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for ServerBackend {
fn name(&self) -> &'static str {
"server"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
Ok(PlatformNode::element(tag))
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
Ok(PlatformNode::text(content))
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"server: child index {} out of bounds",
child_index
)));
}
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"server: replace_child index {} out of bounds",
index
)));
}
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
// On the server, event handlers are emitted as data attributes.
// The client-side hydration pass picks them up and binds real listeners.
if self.hydration_markers {
node.attributes
.push(crate::Attribute::new(format!("data-el-{}", event), "hydrate"));
}
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// Server has no mount concept — rendering is one-shot.
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// Server rendering is stateless — no patch needed.
Ok(())
}
fn supports_ssr(&self) -> bool {
true
}
}
+253
View File
@@ -0,0 +1,253 @@
//! Tests for el-platform backends.
#[cfg(test)]
mod tests {
use crate::{
backend_for, backends::{server::ServerBackend, web::WebBackend},
config::{PlatformConfig, PlatformTarget},
node::PlatformNode,
PlatformBackend,
};
// ── Test 1: PlatformTarget::from_str parses all targets ──────────────────
#[test]
fn test_platform_target_from_str() {
assert_eq!(PlatformTarget::from_str("web"), Some(PlatformTarget::Web));
assert_eq!(PlatformTarget::from_str("server"), Some(PlatformTarget::Server));
assert_eq!(PlatformTarget::from_str("ios"), Some(PlatformTarget::Ios));
assert_eq!(PlatformTarget::from_str("android"), Some(PlatformTarget::Android));
assert_eq!(PlatformTarget::from_str("macos"), Some(PlatformTarget::Macos));
assert_eq!(PlatformTarget::from_str("linux"), Some(PlatformTarget::Linux));
assert_eq!(PlatformTarget::from_str("windows"), Some(PlatformTarget::Windows));
assert_eq!(PlatformTarget::from_str("unknown"), None);
}
// ── Test 2: PlatformTarget::as_str round-trips ───────────────────────────
#[test]
fn test_platform_target_as_str() {
assert_eq!(PlatformTarget::Web.as_str(), "web");
assert_eq!(PlatformTarget::Server.as_str(), "server");
assert_eq!(PlatformTarget::Ios.as_str(), "ios");
}
// ── Test 3: is_native() identifies native targets ────────────────────────
#[test]
fn test_is_native() {
assert!(!PlatformTarget::Web.is_native());
assert!(!PlatformTarget::Server.is_native());
assert!(PlatformTarget::Ios.is_native());
assert!(PlatformTarget::Android.is_native());
assert!(PlatformTarget::Macos.is_native());
assert!(PlatformTarget::Linux.is_native());
assert!(PlatformTarget::Windows.is_native());
}
// ── Test 4: PlatformConfig defaults to web ───────────────────────────────
#[test]
fn test_platform_config_default() {
let cfg = PlatformConfig::default();
assert_eq!(cfg.target, PlatformTarget::Web);
assert!(!cfg.ssr);
}
// ── Test 5: PlatformConfig with_ssr ──────────────────────────────────────
#[test]
fn test_platform_config_with_ssr() {
let cfg = PlatformConfig::new(PlatformTarget::Server).with_ssr(true);
assert_eq!(cfg.target, PlatformTarget::Server);
assert!(cfg.ssr);
}
// ── Test 6: WebBackend renders element to HTML ───────────────────────────
#[test]
fn test_web_backend_renders_element() {
let backend = WebBackend::new();
let mut div = backend.create_element("div").unwrap();
backend.set_attribute(&mut div, "class", "container").unwrap();
backend.append_child(&mut div, backend.create_text("Hello").unwrap()).unwrap();
let html = backend.render_to_string(&div).unwrap();
assert!(html.contains("<div"), "should contain div tag");
assert!(html.contains("container"), "should contain class name");
assert!(html.contains("Hello"), "should contain text content");
assert!(html.contains("</div>"), "should close div tag");
}
// ── Test 7: ServerBackend supports SSR ───────────────────────────────────
#[test]
fn test_server_backend_supports_ssr() {
let backend = ServerBackend::new();
assert!(backend.supports_ssr());
}
// ── Test 8: ServerBackend renders full HTML page ─────────────────────────
#[test]
fn test_server_backend_render_page() {
let backend = ServerBackend::new();
let root = PlatformNode::element("div")
.with_attr("id", "content")
.with_child(PlatformNode::text("Hello World"));
let page = backend.render_page(&root, "My App", "/app.js").unwrap();
assert!(page.contains("<!DOCTYPE html>"), "should be full HTML doc");
assert!(page.contains("My App"), "should include title");
assert!(page.contains("Hello World"), "should include content");
assert!(page.contains("/app.js"), "should include script src");
}
// ── Test 9: PlatformNode::to_html renders nested tree ────────────────────
#[test]
fn test_platform_node_to_html_nested() {
let node = PlatformNode::element("ul")
.with_child(PlatformNode::element("li").with_child(PlatformNode::text("item 1")))
.with_child(PlatformNode::element("li").with_child(PlatformNode::text("item 2")));
let html = node.to_html();
assert!(html.contains("<ul>"));
assert!(html.contains("<li>item 1</li>"));
assert!(html.contains("<li>item 2</li>"));
assert!(html.contains("</ul>"));
}
// ── Test 10: PlatformNode::to_html escapes text content ──────────────────
#[test]
fn test_html_escaping() {
let node = PlatformNode::element("span")
.with_child(PlatformNode::text("<script>alert('xss')</script>"));
let html = node.to_html();
assert!(!html.contains("<script>"), "should escape <script>");
assert!(html.contains("&lt;script&gt;"), "should contain escaped form");
}
// ── Test 11: Void elements render correctly ───────────────────────────────
#[test]
fn test_void_elements() {
let node = PlatformNode::element("input").with_attr("type", "text");
let html = node.to_html();
assert!(html.contains("<input"), "should have input");
assert!(!html.contains("</input>"), "void element should not have closing tag");
assert!(html.contains("/>"), "should self-close");
}
// ── Test 12: Fragment node renders children inline ────────────────────────
#[test]
fn test_fragment_node() {
let frag = PlatformNode::fragment()
.with_child(PlatformNode::element("span").with_child(PlatformNode::text("A")))
.with_child(PlatformNode::element("span").with_child(PlatformNode::text("B")));
let html = frag.to_html();
assert!(html.contains("<span>A</span>"));
assert!(html.contains("<span>B</span>"));
// No wrapping element
assert!(!html.starts_with('<') || html.starts_with("<span>"));
}
// ── Test 13: WebBackend remove_child works ───────────────────────────────
#[test]
fn test_web_backend_remove_child() {
let backend = WebBackend::new();
let mut parent = PlatformNode::element("div");
backend
.append_child(&mut parent, PlatformNode::text("first"))
.unwrap();
backend
.append_child(&mut parent, PlatformNode::text("second"))
.unwrap();
assert_eq!(parent.children.len(), 2);
backend.remove_child(&mut parent, 0).unwrap();
assert_eq!(parent.children.len(), 1);
assert_eq!(parent.children[0].text_content(), Some("second"));
}
// ── Test 14: WebBackend replace_child works ──────────────────────────────
#[test]
fn test_web_backend_replace_child() {
let backend = WebBackend::new();
let mut parent = PlatformNode::element("div");
backend
.append_child(&mut parent, PlatformNode::text("old"))
.unwrap();
backend
.replace_child(&mut parent, 0, PlatformNode::text("new"))
.unwrap();
assert_eq!(parent.children[0].text_content(), Some("new"));
}
// ── Test 15: backend_for() returns correct backend names ─────────────────
#[test]
fn test_backend_for_names() {
assert_eq!(backend_for(&PlatformTarget::Web).name(), "web");
assert_eq!(backend_for(&PlatformTarget::Server).name(), "server");
assert_eq!(backend_for(&PlatformTarget::Ios).name(), "ios");
assert_eq!(backend_for(&PlatformTarget::Android).name(), "android");
assert_eq!(backend_for(&PlatformTarget::Macos).name(), "macos");
assert_eq!(backend_for(&PlatformTarget::Linux).name(), "linux");
assert_eq!(backend_for(&PlatformTarget::Windows).name(), "windows");
}
// ── Test 16: WebBackend bind_event emits data attribute ──────────────────
#[test]
fn test_web_backend_bind_event() {
let backend = WebBackend::new();
let mut btn = backend.create_element("button").unwrap();
backend
.bind_event(&mut btn, "click", Box::new(|_| {}))
.unwrap();
let has_attr = btn.attributes.iter().any(|a| a.name == "data-el-click");
assert!(has_attr, "should emit data-el-click attribute");
}
// ── Test 17: ServerBackend bind_event emits hydration marker ─────────────
#[test]
fn test_server_backend_bind_event() {
let backend = ServerBackend::new();
let mut btn = PlatformNode::element("button");
backend
.bind_event(&mut btn, "click", Box::new(|_| {}))
.unwrap();
let has_attr = btn.attributes.iter().any(|a| a.name.contains("data-el-click"));
assert!(has_attr, "should emit hydration marker for click event");
}
// ── Test 18: WebBackend backend does not support raw SSR flag ────────────
#[test]
fn test_web_backend_supports_ssr() {
// Web backend supports ssr (renders to HTML for hydration)
let backend = WebBackend::new();
assert!(backend.supports_ssr());
}
// ── Test 19: remove_child out of bounds returns error ────────────────────
#[test]
fn test_remove_child_out_of_bounds() {
let backend = WebBackend::new();
let mut parent = PlatformNode::element("div");
let result = backend.remove_child(&mut parent, 5);
assert!(result.is_err(), "out-of-bounds remove should return error");
}
// ── Test 20: All native backends render to HTML for testing ──────────────
#[test]
fn test_native_backends_render_to_html() {
// Native backends support render_to_string for testing and SSR fallback.
let targets = [
PlatformTarget::Ios,
PlatformTarget::Android,
PlatformTarget::Macos,
PlatformTarget::Linux,
PlatformTarget::Windows,
];
for target in &targets {
let backend = backend_for(target);
let node = PlatformNode::element("div")
.with_child(PlatformNode::text("test"));
let html = backend.render_to_string(&node).unwrap();
assert!(
html.contains("test"),
"{} backend should render text content",
target.as_str()
);
}
}
}
+134
View File
@@ -0,0 +1,134 @@
//! Web backend — DOM rendering in browsers.
//!
//! This backend formalizes what `runtime/src/renderer.js` does, as a Rust
//! description of the DOM patching strategy. In a WASM build, this would call
//! into the browser's DOM API directly via `web-sys`.
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
/// Web DOM backend.
///
/// Renders component trees to the browser DOM. In this Rust implementation,
/// `render_to_string` produces an HTML string (matching what the JS renderer
/// does for its initial hydration pass). A full WASM build would instead
/// call `document.createElement()` etc. via `web-sys`.
pub struct WebBackend;
impl WebBackend {
pub fn new() -> Self {
Self
}
}
impl Default for WebBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for WebBackend {
fn name(&self) -> &'static str {
"web"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
Ok(PlatformNode::element(tag))
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
Ok(PlatformNode::text(content))
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// Remove existing attribute with same name then push new one.
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"web: child index {} out of bounds (len {})",
child_index,
parent.children.len()
)));
}
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"web: replace_child index {} out of bounds",
index
)));
}
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
// In a real WASM build: node.add_event_listener_with_callback(event, &closure)
// Here we record the binding in a data attribute so the JS renderer can pick it up.
node.attributes
.push(crate::Attribute::new(format!("data-el-{}", event), "[bound]"));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// In a real WASM build:
// let container = document.query_selector(container_id)?;
// container.set_inner_html(&root.to_html());
// For the Rust-side representation, mount is a no-op.
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// Full DOM patching mirrors renderer.js patch():
// 1. Walk old and new trees in parallel.
// 2. For each position: if node kinds differ, replace; else update attributes.
// 3. Recurse into children.
// In a WASM build this calls web-sys DOM mutation APIs.
Ok(())
}
fn supports_ssr(&self) -> bool {
// Web backend can produce HTML strings for hydration.
true
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Windows backend — Win32/WinUI.
//!
//! Architecture: correct and complete. Win32/WinUI calls are stubs marked TODO.
//!
//! Each `PlatformNode` maps to a HWND or WinUI control:
//! element("div") → Panel (WinUI StackPanel)
//! element("button") → Button (WinUI)
//! element("input") → TextBox (WinUI)
//! text("...") → TextBlock (WinUI)
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct WindowsBackend;
impl WindowsBackend {
pub fn new() -> Self {
Self
}
fn winui_control(tag: &str) -> &'static str {
match tag {
"button" => "Microsoft.UI.Xaml.Controls.Button",
"input" => "Microsoft.UI.Xaml.Controls.TextBox",
"textarea" => "Microsoft.UI.Xaml.Controls.TextBox",
"img" => "Microsoft.UI.Xaml.Controls.Image",
"ul" | "ol" => "Microsoft.UI.Xaml.Controls.ListView",
"li" => "Microsoft.UI.Xaml.Controls.ListViewItem",
"nav" => "Microsoft.UI.Xaml.Controls.NavigationView",
_ => "Microsoft.UI.Xaml.Controls.StackPanel",
}
}
fn winui_event(event: &str) -> &'static str {
match event {
"click" => "Click",
"input" | "change" => "TextChanged",
"focus" => "GotFocus",
"blur" => "LostFocus",
"keydown" | "keypress" => "KeyDown",
"keyup" => "KeyUp",
_ => "PointerPressed",
}
}
}
impl Default for WindowsBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for WindowsBackend {
fn name(&self) -> &'static str {
"windows"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: WinRT/COM activation via windows-rs crate:
// let panel: StackPanel = StackPanel::new()?;
node.attributes
.push(crate::Attribute::new("data-winui-control", Self::winui_control(tag)));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: TextBlock::new()?.set_text(content)
node.attributes
.push(crate::Attribute::new("data-winui-control", "Microsoft.UI.Xaml.Controls.TextBlock"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map to WinUI property setters
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: panel.Children().Append(child_element)
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"windows: child index {} out of bounds",
child_index
)));
}
// TODO: panel.Children().RemoveAt(child_index)
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render(
"windows: replace_child out of bounds".into(),
));
}
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
let winui_event = Self::winui_event(event);
// TODO: button.Click(TypedEventHandler::new(|sender, args| { handler(...) }))
node.attributes.push(crate::Attribute::new(
format!("data-winui-event-{}", event),
winui_event,
));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: get Window by container_id, set root as Content
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff and apply WinUI mutations
Ok(())
}
}
+82
View File
@@ -0,0 +1,82 @@
//! Platform configuration — parsed from `manifest.el`.
/// Which platform to target for rendering.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlatformTarget {
/// Web: DOM rendering in browsers.
Web,
/// Server: SSR — render to HTML string, served by axum.
Server,
/// iOS: UIKit via C FFI / ObjC bridge.
Ios,
/// Android: NDK + JNI bridge.
Android,
/// macOS: AppKit bindings.
Macos,
/// Linux: GTK/Wayland.
Linux,
/// Windows: Win32/WinUI.
Windows,
}
impl PlatformTarget {
/// Parse from the string value used in `manifest.el`.
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"web" => Some(Self::Web),
"server" => Some(Self::Server),
"ios" => Some(Self::Ios),
"android" => Some(Self::Android),
"macos" => Some(Self::Macos),
"linux" => Some(Self::Linux),
"windows" => Some(Self::Windows),
_ => None,
}
}
/// The canonical string name for this target.
pub fn as_str(&self) -> &'static str {
match self {
Self::Web => "web",
Self::Server => "server",
Self::Ios => "ios",
Self::Android => "android",
Self::Macos => "macos",
Self::Linux => "linux",
Self::Windows => "windows",
}
}
/// Whether this target is a native (non-web, non-server) platform.
pub fn is_native(&self) -> bool {
matches!(self, Self::Ios | Self::Android | Self::Macos | Self::Linux | Self::Windows)
}
}
/// Full platform configuration, reflecting the `[platform]` section of `manifest.el`.
#[derive(Debug, Clone)]
pub struct PlatformConfig {
pub target: PlatformTarget,
/// Enable server-side rendering fallback.
pub ssr: bool,
}
impl Default for PlatformConfig {
fn default() -> Self {
Self {
target: PlatformTarget::Web,
ssr: false,
}
}
}
impl PlatformConfig {
pub fn new(target: PlatformTarget) -> Self {
Self { target, ssr: false }
}
pub fn with_ssr(mut self, ssr: bool) -> Self {
self.ssr = ssr;
self
}
}
+126
View File
@@ -0,0 +1,126 @@
//! el-platform — Universal rendering backends for el-ui.
//!
//! The same component code produces native output for every target platform.
//! No bridge. No virtual DOM. Direct platform calls.
//!
//! The target is chosen in `manifest.el`:
//!
//! ```toml
//! [platform]
//! target = "web" # web | server | ios | android | macos | linux | windows
//! ssr = true
//! ```
//!
//! All platforms implement the `PlatformBackend` trait. A future agent fills in
//! the native API calls for iOS/Android/macOS/Linux/Windows — the architecture
//! is correct and complete now.
pub mod backends;
pub mod config;
pub mod node;
pub use backends::{
android::AndroidBackend,
ios::IosBackend,
linux::LinuxBackend,
macos::MacosBackend,
server::ServerBackend,
web::WebBackend,
windows::WindowsBackend,
};
pub use config::{PlatformConfig, PlatformTarget};
pub use node::{Attribute, EventHandler, PlatformNode, PlatformNodeKind};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PlatformError {
#[error("render error: {0}")]
Render(String),
#[error("mount error: {0}")]
Mount(String),
#[error("unsupported operation on target {target}: {op}")]
Unsupported { target: String, op: String },
#[error("event binding error: {0}")]
EventBinding(String),
}
pub type PlatformResult<T> = Result<T, PlatformError>;
/// The core trait every platform backend must implement.
///
/// All rendering paths go through this interface. Component code is identical
/// across targets — only the backend chosen by `manifest.el` differs.
pub trait PlatformBackend: Send + Sync {
/// The platform name (e.g. "web", "server", "ios").
fn name(&self) -> &'static str;
/// Create a new element node on this platform.
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode>;
/// Create a text node on this platform.
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode>;
/// Set an attribute on a node.
fn set_attribute(&self, node: &mut PlatformNode, name: &str, value: &str)
-> PlatformResult<()>;
/// Remove an attribute from a node.
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()>;
/// Append a child node to a parent.
fn append_child(&self, parent: &mut PlatformNode, child: PlatformNode)
-> PlatformResult<()>;
/// Remove a child node from a parent.
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()>;
/// Replace a child node at the given index.
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()>;
/// Bind an event handler to a node.
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
handler: EventHandler,
) -> PlatformResult<()>;
/// Render a node tree to its platform representation.
/// For `server`, this returns an HTML string.
/// For `web`, this patches the live DOM.
/// For native targets, this calls the appropriate native APIs.
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String>;
/// Mount a node tree into the platform's root container.
/// `container_id` is a platform-specific identifier (CSS selector for web,
/// view controller ID for iOS, activity ID for Android, etc.).
fn mount(&self, root: PlatformNode, container_id: &str) -> PlatformResult<()>;
/// Patch an existing mounted tree with a new tree.
/// The backend performs the minimal update needed.
fn patch(&self, old: &PlatformNode, new: &PlatformNode) -> PlatformResult<()>;
/// Whether this backend supports SSR (rendering to HTML string on the server).
fn supports_ssr(&self) -> bool {
false
}
}
/// Select the backend for a given platform target.
pub fn backend_for(target: &PlatformTarget) -> Box<dyn PlatformBackend> {
match target {
PlatformTarget::Web => Box::new(WebBackend::new()),
PlatformTarget::Server => Box::new(ServerBackend::new()),
PlatformTarget::Ios => Box::new(IosBackend::new()),
PlatformTarget::Android => Box::new(AndroidBackend::new()),
PlatformTarget::Macos => Box::new(MacosBackend::new()),
PlatformTarget::Linux => Box::new(LinuxBackend::new()),
PlatformTarget::Windows => Box::new(WindowsBackend::new()),
}
}
+152
View File
@@ -0,0 +1,152 @@
// el-platform Platform abstraction surface for el-ui apps.
//
// The Rust crate defines `PlatformBackend` trait + per-target render backends
// (web/server/ios/android/macos/linux/windows). The El surface is narrower:
// El apps reach the host through the runtime's filesystem, network, and OS
// primitives. This vessel wraps those into a stable, named API so El callers
// don't depend directly on builtin names.
//
// RUNTIME PARITY GAPS:
// - There is no `PlatformBackend` polymorphism at the El layer. The runtime
// IS the backend; target selection happens during compilation/packaging,
// not at runtime.
// - DOM mutation, native widget mounting, event binding none of those
// have El surfaces yet. Use el-ui-compiler when those land.
// - `fs_list` returns a JSON array string; consumers must parse with the
// forthcoming json_array_get builtin. Until that lands, callers should
// treat the value opaquely.
// Targets
fn target_web() -> String { "web" }
fn target_server() -> String { "server" }
fn target_ios() -> String { "ios" }
fn target_android() -> String { "android" }
fn target_macos() -> String { "macos" }
fn target_linux() -> String { "linux" }
fn target_windows() -> String { "windows" }
// The compiled binary's target is fixed at build time. EL_TARGET is set by
// the build harness; absent EL_TARGET, default to "server" (the El runtime
// runs as a host process).
fn platform_target() -> String {
let t: String = env("EL_TARGET")
if str_eq(t, "") { return "server" }
t
}
fn platform_is_native(t: String) -> Bool {
if str_eq(t, "ios") { return true }
if str_eq(t, "android") { return true }
if str_eq(t, "macos") { return true }
if str_eq(t, "linux") { return true }
if str_eq(t, "windows") { return true }
false
}
fn platform_supports_ssr(t: String) -> Bool {
str_eq(t, "server")
}
// Errors
fn err_io() -> String { "platform.io" }
fn err_network() -> String { "platform.network" }
fn err_unsupported() -> String { "platform.unsupported" }
fn err_env_missing() -> String { "platform.env_missing" }
// Environment
fn platform_env(key: String) -> String {
env(key)
}
fn platform_env_or(key: String, fallback: String) -> String {
let v: String = env(key)
if str_eq(v, "") { return fallback }
v
}
// Strict variant: returns "" if missing, callers branch.
// (Runtime has no panic() fail-soft return preserves type.)
fn platform_env_required(key: String) -> String {
let v: String = env(key)
if str_eq(v, "") {
println("[el-platform] ERROR " + err_env_missing() + ":" + key)
return ""
}
v
}
// Filesystem
fn platform_fs_read(path: String) -> String {
fs_read(path)
}
fn platform_fs_write(path: String, content: String) -> Bool {
fs_write(path, content)
true
}
// Returns a JSON array of entry names (opaque until json_array_get lands).
fn platform_fs_list(path: String) -> String {
let raw: String = fs_list(path)
if str_eq(raw, "") { return "[]" }
raw
}
fn platform_fs_exists(path: String) -> Bool {
let raw: String = fs_read(path)
!str_eq(raw, "")
}
// HTTP
fn platform_http_get(url: String) -> String {
http_get(url)
}
fn platform_http_post(url: String, body: String) -> String {
http_post(url, body)
}
fn platform_http_post_json(url: String, json_body: String) -> String {
http_post_json(url, json_body)
}
fn platform_http_get_authed(url: String, bearer: String) -> String {
let headers: String = "Authorization: Bearer " + bearer
http_get_with_headers(url, headers)
}
fn platform_http_post_authed(url: String, body: String, bearer: String) -> String {
let headers: String = "Authorization: Bearer " + bearer
http_post_with_headers(url, body, headers)
}
// Time / OS clock
fn platform_now() -> Int {
time_now()
}
fn platform_sleep_ms(ms: Int) -> Bool {
sleep_ms(ms)
true
}
// Identity (for trace/log correlation)
fn platform_uuid() -> String {
uuid_v4()
}
// Entry smoke test
//
// elc-bug-workaround: top-level `let` does not scope into the surrounding
// statement substitution in the entry block, so we call platform_target()
// inline at each use site.
println("[el-platform] target=" + platform_target() + " ssr=" + bool_to_str(platform_supports_ssr(platform_target())))
println("[el-platform] uuid=" + platform_uuid())
+158
View File
@@ -0,0 +1,158 @@
//! Platform-agnostic node tree.
//!
//! `PlatformNode` is the universal representation of a UI element.
//! Each backend converts this to its native equivalent.
/// An attribute on a platform node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attribute {
pub name: String,
pub value: String,
}
impl Attribute {
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self { name: name.into(), value: value.into() }
}
}
/// A boxed event handler function.
/// Using `Box<dyn Fn(String)>` so platform nodes can store handlers without
/// knowing the native event type.
pub type EventHandler = Box<dyn Fn(String) + Send + Sync>;
/// The kind of a platform node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlatformNodeKind {
/// An element node: `<div>`, `<button>`, etc.
Element { tag: String },
/// A plain text node.
Text { content: String },
/// A component boundary marker (used for patch reconciliation).
Component { name: String },
/// A fragment — groups children without a wrapper element.
Fragment,
}
/// A platform-agnostic UI node.
///
/// This is the universal intermediate representation. Each backend renders
/// `PlatformNode` trees to its native format.
#[derive(Debug)]
pub struct PlatformNode {
pub kind: PlatformNodeKind,
pub attributes: Vec<Attribute>,
pub children: Vec<PlatformNode>,
/// Opaque platform handle — the backend stores its native pointer/reference
/// here after mounting. `None` before mount.
pub native_handle: Option<usize>,
}
impl PlatformNode {
/// Create an element node.
pub fn element(tag: impl Into<String>) -> Self {
Self {
kind: PlatformNodeKind::Element { tag: tag.into() },
attributes: Vec::new(),
children: Vec::new(),
native_handle: None,
}
}
/// Create a text node.
pub fn text(content: impl Into<String>) -> Self {
Self {
kind: PlatformNodeKind::Text { content: content.into() },
attributes: Vec::new(),
children: Vec::new(),
native_handle: None,
}
}
/// Create a fragment node.
pub fn fragment() -> Self {
Self {
kind: PlatformNodeKind::Fragment,
attributes: Vec::new(),
children: Vec::new(),
native_handle: None,
}
}
/// Add an attribute.
pub fn with_attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.attributes.push(Attribute::new(name, value));
self
}
/// Add a child node.
pub fn with_child(mut self, child: PlatformNode) -> Self {
self.children.push(child);
self
}
/// Get the tag name if this is an element node.
pub fn tag(&self) -> Option<&str> {
match &self.kind {
PlatformNodeKind::Element { tag } => Some(tag),
_ => None,
}
}
/// Get the text content if this is a text node.
pub fn text_content(&self) -> Option<&str> {
match &self.kind {
PlatformNodeKind::Text { content } => Some(content),
_ => None,
}
}
/// Render the node tree to an HTML string.
/// This is used by the server backend and for testing all backends.
pub fn to_html(&self) -> String {
match &self.kind {
PlatformNodeKind::Text { content } => html_escape(content),
PlatformNodeKind::Fragment => {
self.children.iter().map(|c| c.to_html()).collect()
}
PlatformNodeKind::Component { name } => {
format!("<!-- component:{} -->", name)
}
PlatformNodeKind::Element { tag } => {
let mut out = format!("<{}", tag);
for attr in &self.attributes {
out.push_str(&format!(" {}=\"{}\"", attr.name, html_escape_attr(&attr.value)));
}
// Void elements — no closing tag
if is_void_element(tag) {
out.push_str(" />");
return out;
}
out.push('>');
for child in &self.children {
out.push_str(&child.to_html());
}
out.push_str(&format!("</{}>", tag));
out
}
}
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn html_escape_attr(s: &str) -> String {
html_escape(s).replace('"', "&quot;")
}
fn is_void_element(tag: &str) -> bool {
matches!(
tag,
"area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input"
| "link" | "meta" | "param" | "source" | "track" | "wbr"
)
}