Archived
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5e34ed09b | |||
| f4abfe6fdc | |||
| f09803c317 |
+12
-12
@@ -1,17 +1,17 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/el-ui-compiler",
|
||||
"crates/el-platform",
|
||||
"crates/el-services",
|
||||
"crates/el-aop",
|
||||
"crates/el-auth",
|
||||
"crates/el-publish",
|
||||
"crates/el-identity",
|
||||
"crates/el-style",
|
||||
"crates/el-layout",
|
||||
"crates/el-i18n",
|
||||
"crates/el-config",
|
||||
"crates/el-secrets",
|
||||
"vessels/el-ui-compiler",
|
||||
"vessels/el-platform",
|
||||
"vessels/el-services",
|
||||
"vessels/el-aop",
|
||||
"vessels/el-auth",
|
||||
"vessels/el-publish",
|
||||
"vessels/el-identity",
|
||||
"vessels/el-style",
|
||||
"vessels/el-layout",
|
||||
"vessels/el-i18n",
|
||||
"vessels/el-config",
|
||||
"vessels/el-secrets",
|
||||
"examples/profile-card",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -1,581 +0,0 @@
|
||||
//! Code generator — transforms el-ui AST into JavaScript module source.
|
||||
//!
|
||||
//! Each component becomes a class that:
|
||||
//! 1. Extends `Component` from the el-ui runtime.
|
||||
//! 2. Stores each `state` field as a node in an in-instance `Graph`.
|
||||
//! 3. Implements `render()` returning an HTML string.
|
||||
//! 4. Uses `setState()` to trigger spreading activation and DOM patching.
|
||||
//!
|
||||
//! ## Codegen targets
|
||||
//!
|
||||
//! The `CodegenTarget` enum selects the output format:
|
||||
//!
|
||||
//! - `Web` — ES2022 module (current, default behavior)
|
||||
//! - `Server` — Rust code calling the `el-platform` server backend for SSR
|
||||
//! - `Native(Platform)` — Rust code calling the `el-platform` native backend trait
|
||||
|
||||
use crate::ast::*;
|
||||
use crate::error::CompileResult;
|
||||
|
||||
/// Which native platform to target when using `CodegenTarget::Native`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Platform {
|
||||
Ios,
|
||||
Android,
|
||||
Macos,
|
||||
Linux,
|
||||
Windows,
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ios => "ios",
|
||||
Self::Android => "android",
|
||||
Self::Macos => "macos",
|
||||
Self::Linux => "linux",
|
||||
Self::Windows => "windows",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects the output format for the code generator.
|
||||
///
|
||||
/// - `Web` — ES2022 JavaScript module (default, uses the el-ui JS runtime)
|
||||
/// - `Server` — Rust module that uses `el_platform::ServerBackend` for SSR.
|
||||
/// The generated Rust struct implements a `render_to_html()` method.
|
||||
/// - `Native(Platform)` — Rust module calling `el_platform::PlatformBackend`
|
||||
/// for the specified native platform (iOS, Android, macOS, Linux, Windows).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CodegenTarget {
|
||||
/// Current default — ES2022 JavaScript module for the browser.
|
||||
Web,
|
||||
/// Server-side rendering — generates Rust code using `el_platform::ServerBackend`.
|
||||
Server,
|
||||
/// Native platform — generates Rust code using the platform backend trait.
|
||||
Native(Platform),
|
||||
}
|
||||
|
||||
impl CodegenTarget {
|
||||
pub fn is_web(&self) -> bool {
|
||||
matches!(self, Self::Web)
|
||||
}
|
||||
|
||||
pub fn is_rust_output(&self) -> bool {
|
||||
matches!(self, Self::Server | Self::Native(_))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Codegen {
|
||||
runtime_path: String,
|
||||
/// The compilation target. Defaults to `Web`.
|
||||
pub target: CodegenTarget,
|
||||
}
|
||||
|
||||
impl Codegen {
|
||||
pub fn new(runtime_path: &str) -> Self {
|
||||
Self {
|
||||
runtime_path: runtime_path.to_owned(),
|
||||
target: CodegenTarget::Web,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_target(mut self, target: CodegenTarget) -> Self {
|
||||
self.target = target;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn generate(&self, components: &[Component]) -> CompileResult<String> {
|
||||
match &self.target {
|
||||
CodegenTarget::Web => self.generate_web(components),
|
||||
CodegenTarget::Server => self.generate_server_rust(components),
|
||||
CodegenTarget::Native(platform) => self.generate_native_rust(components, platform),
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_web(&self, components: &[Component]) -> CompileResult<String> {
|
||||
let mut out = String::new();
|
||||
|
||||
// Runtime import
|
||||
out.push_str(&format!(
|
||||
"import {{ Component, Graph, Renderer, Router, mount }} from '{}';\n\n",
|
||||
self.runtime_path
|
||||
));
|
||||
|
||||
for component in components {
|
||||
out.push_str(&self.gen_component(component)?);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// Export all component names
|
||||
let names: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
|
||||
if !names.is_empty() {
|
||||
out.push_str(&format!("export {{ {} }};\n", names.join(", ")));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Generate Rust code for the `server` target.
|
||||
///
|
||||
/// The output is a Rust module where each component is a struct implementing
|
||||
/// `render_to_html(&self) -> String` using `el_platform::ServerBackend`.
|
||||
fn generate_server_rust(&self, components: &[Component]) -> CompileResult<String> {
|
||||
let mut out = String::new();
|
||||
out.push_str("//! el-ui SSR — generated by el-ui-compiler (target: server)\n");
|
||||
out.push_str("//! Do not edit. Re-generate with: el-ui-compiler --target server\n\n");
|
||||
out.push_str("use el_platform::{ServerBackend, PlatformBackend, PlatformNode};\n\n");
|
||||
|
||||
for comp in components {
|
||||
out.push_str(&format!("/// Server-side rendered component: {}\n", comp.name));
|
||||
out.push_str(&format!("pub struct {} {{\n", comp.name));
|
||||
for prop in &comp.props {
|
||||
out.push_str(&format!(" pub {}: String,\n", prop.name));
|
||||
}
|
||||
for state in &comp.state {
|
||||
out.push_str(&format!(" pub {}: String,\n", state.name));
|
||||
}
|
||||
out.push_str("}\n\n");
|
||||
|
||||
out.push_str(&format!("impl {} {{\n", comp.name));
|
||||
out.push_str(" pub fn render_to_html(&self) -> String {\n");
|
||||
out.push_str(" let backend = ServerBackend::new();\n");
|
||||
// Generate a simple element tree based on the template
|
||||
out.push_str(" let root = self.build_node_tree();\n");
|
||||
out.push_str(" backend.render_to_string(&root).unwrap_or_default()\n");
|
||||
out.push_str(" }\n\n");
|
||||
out.push_str(" fn build_node_tree(&self) -> PlatformNode {\n");
|
||||
out.push_str(" // TODO: full template → PlatformNode tree codegen\n");
|
||||
out.push_str(" // The compiler translates each TemplateNode into\n");
|
||||
out.push_str(" // PlatformNode::element() / PlatformNode::text() calls.\n");
|
||||
out.push_str(" PlatformNode::element(\"div\")\n");
|
||||
out.push_str(" }\n");
|
||||
out.push_str("}\n\n");
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Generate Rust code for a `native` target.
|
||||
///
|
||||
/// The output is a Rust module where each component builds a `PlatformNode`
|
||||
/// tree and calls the appropriate backend to mount it.
|
||||
fn generate_native_rust(&self, components: &[Component], platform: &Platform) -> CompileResult<String> {
|
||||
let backend_type = match platform {
|
||||
Platform::Ios => "IosBackend",
|
||||
Platform::Android => "AndroidBackend",
|
||||
Platform::Macos => "MacosBackend",
|
||||
Platform::Linux => "LinuxBackend",
|
||||
Platform::Windows => "WindowsBackend",
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"//! el-ui native — generated by el-ui-compiler (target: {})\n",
|
||||
platform.as_str()
|
||||
));
|
||||
out.push_str("//! Do not edit. Re-generate with: el-ui-compiler --target <platform>\n\n");
|
||||
out.push_str(&format!(
|
||||
"use el_platform::{{{} as Backend, PlatformBackend, PlatformNode}};\n\n",
|
||||
backend_type
|
||||
));
|
||||
|
||||
for comp in components {
|
||||
out.push_str(&format!("/// Native component: {} ({})\n", comp.name, platform.as_str()));
|
||||
out.push_str(&format!("pub struct {} {{\n", comp.name));
|
||||
for prop in &comp.props {
|
||||
out.push_str(&format!(" pub {}: String,\n", prop.name));
|
||||
}
|
||||
for state in &comp.state {
|
||||
out.push_str(&format!(" pub {}: String,\n", state.name));
|
||||
}
|
||||
out.push_str(" backend: Backend,\n");
|
||||
out.push_str("}\n\n");
|
||||
|
||||
out.push_str(&format!("impl {} {{\n", comp.name));
|
||||
out.push_str(" pub fn new() -> Self {\n");
|
||||
out.push_str(" Self {\n");
|
||||
for prop in &comp.props {
|
||||
let default = prop.default.as_deref().unwrap_or("\"\"");
|
||||
out.push_str(&format!(" {}: {}.to_string(),\n", prop.name, default));
|
||||
}
|
||||
for state in &comp.state {
|
||||
out.push_str(&format!(" {}: {}.to_string(),\n", state.name, state.initial));
|
||||
}
|
||||
out.push_str(" backend: Backend::new(),\n");
|
||||
out.push_str(" }\n");
|
||||
out.push_str(" }\n\n");
|
||||
out.push_str(" pub fn mount(&self, container_id: &str) -> el_platform::PlatformResult<()> {\n");
|
||||
out.push_str(" let root = self.build_node_tree();\n");
|
||||
out.push_str(" self.backend.mount(root, container_id)\n");
|
||||
out.push_str(" }\n\n");
|
||||
out.push_str(" fn build_node_tree(&self) -> PlatformNode {\n");
|
||||
out.push_str(" // TODO: full template → PlatformNode tree codegen\n");
|
||||
out.push_str(" PlatformNode::element(\"div\")\n");
|
||||
out.push_str(" }\n");
|
||||
out.push_str("}\n\n");
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn gen_component(&self, comp: &Component) -> CompileResult<String> {
|
||||
let mut out = String::new();
|
||||
|
||||
out.push_str(&format!("class {} extends Component {{\n", comp.name));
|
||||
|
||||
// constructor
|
||||
out.push_str(" constructor(props = {}) {\n");
|
||||
out.push_str(" super();\n");
|
||||
out.push_str(" this.props = props;\n");
|
||||
out.push_str(" this._graph = new Graph();\n");
|
||||
out.push_str(" this._stateNodes = {};\n");
|
||||
out.push_str(" this._state = {};\n");
|
||||
|
||||
// Validate and set props
|
||||
if !comp.props.is_empty() {
|
||||
out.push_str(" // Props\n");
|
||||
for prop in &comp.props {
|
||||
let default_js = prop.default.as_deref()
|
||||
.map(|d| translate_el_to_js(d))
|
||||
.unwrap_or_else(|| "undefined".to_owned());
|
||||
out.push_str(&format!(
|
||||
" this._props_{name} = props.{name} !== undefined ? props.{name} : {default};\n",
|
||||
name = prop.name,
|
||||
default = default_js,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Seed state nodes
|
||||
if !comp.state.is_empty() {
|
||||
out.push_str(" // State nodes (Engram graph seeds)\n");
|
||||
for s in &comp.state {
|
||||
let initial_js = translate_el_to_js(&s.initial);
|
||||
out.push_str(&format!(
|
||||
" this._stateNodes['{name}'] = this._graph.seed({{ type: 'state', name: '{name}', content: {initial} }});\n",
|
||||
name = s.name,
|
||||
initial = initial_js,
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" this._state['{name}'] = {initial};\n",
|
||||
name = s.name,
|
||||
initial = initial_js,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to state node changes for reactive re-render
|
||||
if !comp.state.is_empty() {
|
||||
out.push_str(" // Subscribe to graph activation events\n");
|
||||
out.push_str(" for (const [key, nodeId] of Object.entries(this._stateNodes)) {\n");
|
||||
out.push_str(" this._graph.subscribe(nodeId, (node) => {\n");
|
||||
out.push_str(" this._state[key] = node.content;\n");
|
||||
out.push_str(" if (this._renderer) this._renderer.patch();\n");
|
||||
out.push_str(" });\n");
|
||||
out.push_str(" }\n");
|
||||
}
|
||||
|
||||
out.push_str(" }\n\n");
|
||||
|
||||
// setState method
|
||||
out.push_str(" setState(name, value) {\n");
|
||||
out.push_str(" if (this._stateNodes[name] !== undefined) {\n");
|
||||
out.push_str(" this._graph.update(this._stateNodes[name], value);\n");
|
||||
out.push_str(" }\n");
|
||||
out.push_str(" }\n\n");
|
||||
|
||||
// User-defined methods
|
||||
for method in &comp.methods {
|
||||
out.push_str(&self.gen_method(method, comp)?);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// render()
|
||||
out.push_str(" render() {\n");
|
||||
out.push_str(" const __self = this;\n");
|
||||
|
||||
// Expose state variables
|
||||
for s in &comp.state {
|
||||
out.push_str(&format!(
|
||||
" const {name} = this._state['{name}'];\n",
|
||||
name = s.name,
|
||||
));
|
||||
}
|
||||
// Expose props
|
||||
for p in &comp.props {
|
||||
out.push_str(&format!(
|
||||
" const {name} = this._props_{name};\n",
|
||||
name = p.name,
|
||||
));
|
||||
}
|
||||
|
||||
out.push_str(" return `");
|
||||
let template_js = self.gen_template_nodes(&comp.template.nodes, comp)?;
|
||||
out.push_str(&template_js);
|
||||
out.push_str("`;\n");
|
||||
out.push_str(" }\n\n");
|
||||
|
||||
out.push_str("}\n");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn gen_method(&self, method: &Method, comp: &Component) -> CompileResult<String> {
|
||||
let mut out = String::new();
|
||||
let params: Vec<String> = method.params.iter()
|
||||
.map(|(n, _)| n.clone())
|
||||
.collect();
|
||||
|
||||
out.push_str(&format!(
|
||||
" {}({}) {{\n",
|
||||
method.name,
|
||||
params.join(", ")
|
||||
));
|
||||
|
||||
// Expose state in method body
|
||||
for s in &comp.state {
|
||||
out.push_str(&format!(
|
||||
" const {name} = this._state['{name}'];\n",
|
||||
name = s.name
|
||||
));
|
||||
}
|
||||
|
||||
// Translate body — simple pass-through with setState substitution
|
||||
let body = translate_method_body(&method.body, comp);
|
||||
for line in body.lines() {
|
||||
out.push_str(&format!(" {}\n", line));
|
||||
}
|
||||
|
||||
out.push_str(" }\n");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn gen_template_nodes(&self, nodes: &[TemplateNode], comp: &Component) -> CompileResult<String> {
|
||||
let mut out = String::new();
|
||||
for node in nodes {
|
||||
out.push_str(&self.gen_template_node(node, comp)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn gen_template_node(&self, node: &TemplateNode, comp: &Component) -> CompileResult<String> {
|
||||
match node {
|
||||
TemplateNode::Text(t) => Ok(t.clone()),
|
||||
|
||||
TemplateNode::Interpolation(expr) => {
|
||||
let js_expr = translate_interpolation(expr, comp);
|
||||
Ok(format!("${{{} }}", js_expr))
|
||||
}
|
||||
|
||||
TemplateNode::Element { tag, attrs, children } => {
|
||||
let mut out = format!("<{}", tag);
|
||||
|
||||
for attr in attrs {
|
||||
out.push_str(&self.gen_attr(attr, comp)?);
|
||||
}
|
||||
|
||||
if children.is_empty() {
|
||||
out.push_str(&format!(" data-el-tag=\"{}\">", tag));
|
||||
out.push_str(&format!("</{}>", tag));
|
||||
} else {
|
||||
out.push_str(&format!(" data-el-tag=\"{}\">", tag));
|
||||
out.push_str(&self.gen_template_nodes(children, comp)?);
|
||||
out.push_str(&format!("</{}>", tag));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
TemplateNode::Component { name, props } => {
|
||||
// Render as inline component call
|
||||
let mut prop_entries: Vec<String> = Vec::new();
|
||||
for prop in props {
|
||||
match prop {
|
||||
Attr::Static { name: pn, value } => {
|
||||
prop_entries.push(format!("{}: \"{}\"", pn, value));
|
||||
}
|
||||
Attr::Dynamic { name: pn, expr } => {
|
||||
let js = translate_interpolation(expr, comp);
|
||||
prop_entries.push(format!("{}: {}", pn, js));
|
||||
}
|
||||
Attr::EventHandler { event, handler } => {
|
||||
let js = translate_handler(handler, comp);
|
||||
prop_entries.push(format!("on{}: {}", capitalize(event), js));
|
||||
}
|
||||
Attr::BoolAttr { name: pn, expr } => {
|
||||
prop_entries.push(format!("{}: {}", pn, expr));
|
||||
}
|
||||
}
|
||||
}
|
||||
let props_js = format!("{{ {} }}", prop_entries.join(", "));
|
||||
Ok(format!("${{__self._child({}, {})}}", name, props_js))
|
||||
}
|
||||
|
||||
TemplateNode::If { condition, then, else_ } => {
|
||||
let cond_js = translate_interpolation(condition, comp);
|
||||
let then_html = self.gen_template_nodes(then, comp)?;
|
||||
let else_html = if let Some(els) = else_ {
|
||||
self.gen_template_nodes(els, comp)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Ok(format!(
|
||||
"${{({}) ? `{}` : `{}`}}",
|
||||
cond_js, then_html, else_html
|
||||
))
|
||||
}
|
||||
|
||||
TemplateNode::Each { items, item_name, children } => {
|
||||
let items_js = translate_interpolation(items, comp);
|
||||
let child_html = self.gen_template_nodes(children, comp)?;
|
||||
// Generate a map over the array
|
||||
Ok(format!(
|
||||
"${{({}).map(({}) => `{}`).join('')}}",
|
||||
items_js, item_name, child_html
|
||||
))
|
||||
}
|
||||
|
||||
TemplateNode::Activate { query, result_name, children } => {
|
||||
let child_html = self.gen_template_nodes(children, comp)?;
|
||||
Ok(format!(
|
||||
"${{((__self._graph.search(\"{}\")) || []).map(({}) => `{}`).join('')}}",
|
||||
query, result_name, child_html
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_attr(&self, attr: &Attr, comp: &Component) -> CompileResult<String> {
|
||||
match attr {
|
||||
Attr::Static { name, value } => {
|
||||
Ok(format!(" {}=\"{}\"", name, value))
|
||||
}
|
||||
Attr::Dynamic { name, expr } => {
|
||||
let js = translate_interpolation(expr, comp);
|
||||
Ok(format!(" {}=\"${{{} }}\"", name, js))
|
||||
}
|
||||
Attr::BoolAttr { name, expr } => {
|
||||
let js = translate_interpolation(expr, comp);
|
||||
Ok(format!(" ${{({}) ? '{}' : '' }}", js, name))
|
||||
}
|
||||
Attr::EventHandler { event, handler } => {
|
||||
// We use data attributes to defer event binding
|
||||
let js = translate_handler(handler, comp);
|
||||
// Inline handler via data attribute — the renderer will bind these
|
||||
Ok(format!(" data-el-{}=\"{}\"", event, escape_attr(&js)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate an el-ui expression to JavaScript.
|
||||
/// Handles state assignments like `count = count + 1` → `__self.setState('count', count + 1)`
|
||||
fn translate_interpolation(expr: &str, comp: &Component) -> String {
|
||||
translate_expr(expr, comp)
|
||||
}
|
||||
|
||||
fn translate_expr(expr: &str, comp: &Component) -> String {
|
||||
let state_names: Vec<&str> = comp.state.iter().map(|s| s.name.as_str()).collect();
|
||||
// Arrow functions: passthrough
|
||||
// State assignment: `name = value` → `__self.setState('name', value)`
|
||||
let trimmed = expr.trim();
|
||||
|
||||
// Check for simple assignment: `ident = expr`
|
||||
if let Some(result) = try_translate_assignment(trimmed, &state_names) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Arrow function containing assignment: `() => count = count + 1`
|
||||
if trimmed.starts_with('(') || trimmed.starts_with("e =>") || trimmed.starts_with("() =>") {
|
||||
return translate_arrow_fn(trimmed, &state_names);
|
||||
}
|
||||
|
||||
// Otherwise pass through as-is
|
||||
trimmed.to_owned()
|
||||
}
|
||||
|
||||
fn try_translate_assignment(expr: &str, state_names: &[&str]) -> Option<String> {
|
||||
// Match: `name = value` where name is a state variable
|
||||
// Must not be `==` (equality)
|
||||
let parts: Vec<&str> = expr.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
let lhs = parts[0].trim();
|
||||
let rhs = parts[1].trim();
|
||||
// Ensure it's not `==` or `!=` or `<=` or `>=`
|
||||
if !rhs.starts_with('=') && !lhs.ends_with('!') && !lhs.ends_with('<') && !lhs.ends_with('>') {
|
||||
if state_names.contains(&lhs) {
|
||||
return Some(format!("__self.setState('{}', {})", lhs, rhs));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn translate_arrow_fn(expr: &str, state_names: &[&str]) -> String {
|
||||
// Translate assignments inside arrow functions
|
||||
// This is a best-effort string transformation
|
||||
let mut result = expr.to_owned();
|
||||
for name in state_names {
|
||||
// Replace `name = ` with `__self.setState('name', ` ... `)` is too complex
|
||||
// for a simple string replacement, but we can handle common patterns.
|
||||
// Pattern: `name = expr` at end of arrow fn or in braces
|
||||
let pat = format!("{} = ", name);
|
||||
if let Some(idx) = result.find(&pat) {
|
||||
// Check it's not ==
|
||||
let after = &result[idx + pat.len()..];
|
||||
// Simple case: `() => count = count + 1`
|
||||
let prefix = &result[..idx];
|
||||
result = format!("{}__self.setState('{}', {})", prefix, name, after.trim_end_matches(')'));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn translate_handler(handler: &str, comp: &Component) -> String {
|
||||
translate_expr(handler, comp)
|
||||
}
|
||||
|
||||
/// Translate method body — replace bare state assignments with setState calls.
|
||||
fn translate_method_body(body: &str, comp: &Component) -> String {
|
||||
let state_names: Vec<&str> = comp.state.iter().map(|s| s.name.as_str()).collect();
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for line in body.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(translated) = try_translate_assignment(trimmed, &state_names) {
|
||||
lines.push(format!("{};", translated));
|
||||
} else if trimmed.starts_with("return ") {
|
||||
lines.push(trimmed.to_owned());
|
||||
} else {
|
||||
lines.push(trimmed.to_owned());
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn translate_el_to_js(expr: &str) -> String {
|
||||
let s = expr.trim();
|
||||
// Fn types — translate to null (not a valid JS value, handled at runtime)
|
||||
if s.starts_with("Fn") { return "null".into(); }
|
||||
// Boolean
|
||||
if s == "true" { return "true".into(); }
|
||||
if s == "false" { return "false".into(); }
|
||||
// String literal
|
||||
if s.starts_with('"') { return s.replace('"', "\"").to_owned(); }
|
||||
// Numbers
|
||||
if s.parse::<i64>().is_ok() { return s.to_owned(); }
|
||||
if s.parse::<f64>().is_ok() { return s.to_owned(); }
|
||||
// Empty string / void
|
||||
if s.is_empty() { return "null".into(); }
|
||||
s.to_owned()
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_attr(s: &str) -> String {
|
||||
s.replace('"', """).replace('\'', "'")
|
||||
}
|
||||
@@ -56,6 +56,3 @@ class App extends Component {
|
||||
}
|
||||
|
||||
export { Counter, App };
|
||||
|
||||
// Mount the app
|
||||
mount(App, '#app');
|
||||
|
||||
@@ -9,8 +9,8 @@ name = "profile-card"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
el-style = { path = "../../crates/el-style" }
|
||||
el-layout = { path = "../../crates/el-layout" }
|
||||
el-i18n = { path = "../../crates/el-i18n" }
|
||||
el-config = { path = "../../crates/el-config" }
|
||||
el-secrets = { path = "../../crates/el-secrets" }
|
||||
el-style = { path = "../../vessels/el-style" }
|
||||
el-layout = { path = "../../vessels/el-layout" }
|
||||
el-i18n = { path = "../../vessels/el-i18n" }
|
||||
el-config = { path = "../../vessels/el-config" }
|
||||
el-secrets = { path = "../../vessels/el-secrets" }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! - Styling via semantic tokens and the StyleModifier trait
|
||||
//! - Responsive layout: VStack/HStack that wrap automatically
|
||||
//! - Localization via LocaleContext and t()/t_plural()
|
||||
//! - Configuration from el.toml / env vars
|
||||
//! - Configuration from manifest.el / env vars
|
||||
//! - Secrets that never appear in logs
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -183,7 +183,7 @@ app.debug = "true"
|
||||
"#;
|
||||
|
||||
let toml_source = load_from_toml(el_toml, &Environment::Development)
|
||||
.expect("el.toml should be valid");
|
||||
.expect("manifest.el should be valid");
|
||||
|
||||
let mut config = Config::new(Environment::Development);
|
||||
config.push_source(Box::new(toml_source));
|
||||
|
||||
+425
-302
@@ -1,76 +1,98 @@
|
||||
# el-ui Framework Specification
|
||||
|
||||
Version 0.1.0 — April 2026
|
||||
Version 1.1.0 — April 30, 2026
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
el-ui is a frontend framework where state is an Engram graph and reactivity is spreading activation.
|
||||
|
||||
Every framework answers the same question differently: **what re-renders when state changes?**
|
||||
el-ui is a frontend framework where state is an Engram graph and reactivity is spreading activation. Every framework must answer the same question: what re-renders when state changes? el-ui's answer is: whatever the spreading activation algorithm determines is relevant.
|
||||
|
||||
| Framework | Reactivity model |
|
||||
|-----------|-----------------|
|
||||
| React | Virtual DOM diffing |
|
||||
| Vue | Dependency tracking (Proxy-based) |
|
||||
| Svelte | Compile-time analysis |
|
||||
| **el-ui** | **Spreading activation over a state graph** |
|
||||
| Vue | Dependency tracking (Proxy-based reactive primitives) |
|
||||
| Svelte | Compile-time static analysis |
|
||||
| Solid | Fine-grained signal-based subscriptions |
|
||||
| **el-ui** | **Spreading activation over a typed state graph** |
|
||||
|
||||
The core insight: treating component state as a graph of related nodes, and using the same spreading activation algorithm as the Engram knowledge engine to determine what to update. Reactivity is not declared, tracked, or diffed — it is activated and propagated, exactly as associative memory works in biological neural networks.
|
||||
The core insight: component state is represented as a graph of related nodes, and the same spreading activation algorithm used by the Engram knowledge engine determines which components need to update. Reactivity is not declared, tracked, or diffed — it is activated and propagated, exactly as associative memory works in biological neural networks.
|
||||
|
||||
---
|
||||
|
||||
## 1. Activation-Based Reactivity
|
||||
## 1. Activation-Based Reactivity Model
|
||||
|
||||
### 1.1 The Model
|
||||
|
||||
Every piece of state in an el-ui application is a **node** in an in-browser Engram graph. Nodes have:
|
||||
|
||||
- `id` — UUID, the node's identity
|
||||
- `type` — `'state'`, `'route'`, or user-defined
|
||||
- `name` — the state variable name
|
||||
- `content` — the current value
|
||||
- `importance` — a salience weight `[0, 1]`, boosted when the node is activated
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | String (UUID) | Node's permanent identity |
|
||||
| `type` | String | `'state'`, `'route'`, or user-defined |
|
||||
| `name` | String | State variable name |
|
||||
| `content` | Any | Current value |
|
||||
| `importance` | Float [0, 1] | Salience weight; boosted on update |
|
||||
|
||||
Nodes are connected by **edges** with a `weight` `[0, 1]` and a `relation` label.
|
||||
Nodes are connected by directed **edges** with:
|
||||
|
||||
When state changes (via `setState()`), spreading activation runs from the changed node:
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `weight` | Float [0, 1] | Connection strength |
|
||||
| `relation` | String | Semantic label of the relationship |
|
||||
|
||||
### 1.2 Activation Formula
|
||||
|
||||
When state changes via `setState()`, spreading activation runs from the changed node through the graph:
|
||||
|
||||
```
|
||||
strength = parent_strength × edge.weight × target.importance
|
||||
```
|
||||
|
||||
This is multiplicative, matching the Engram core engine (`engram-core/src/activation.rs`). Every factor must be non-trivial for a path to propagate — weak edges, dormant nodes, and irrelevant connections die immediately. Components subscribed to nodes in the resulting **activation surface** update. Everything else stays still.
|
||||
This formula is identical to the Engram core spreading activation formula, with the semantic similarity factor (cosine_sim) omitted because the in-browser graph does not yet carry embedding vectors. In a future version connected to the Engram server, the full four-factor formula applies:
|
||||
|
||||
### 1.2 Why Multiplication, Not Addition
|
||||
```
|
||||
strength = parent_strength × edge.weight × target.importance × cosine_sim(query, target)
|
||||
```
|
||||
|
||||
Addition allows many weak signals to accumulate into false relevance — an observation from the Engram architecture. The brain's associative memory is conjunctive: a path requires ALL its links to be strong. Multiplication enforces this. If any factor is near zero, the path dies.
|
||||
### 1.3 Why Multiplication, Not Addition
|
||||
|
||||
### 1.3 Pruning
|
||||
Addition allows many weak signals to accumulate into false relevance. The brain's associative memory is conjunctive: an activated path requires ALL its links to be strong. Multiplication enforces this. If any factor is near zero — weak edge, dormant node, or irrelevant connection — the path dies immediately. This is not a performance optimization; it is a semantic property of associative retrieval.
|
||||
|
||||
Paths with activation strength below `PRUNE_THRESHOLD` (default: `0.01`) are cut. This prevents exponential blowup in large state graphs and models the brain's attention filter.
|
||||
### 1.4 Pruning Threshold
|
||||
|
||||
### 1.4 Importance Boost
|
||||
Paths with activation strength below `PRUNE_THRESHOLD` (default: `0.01`) are cut. This prevents exponential blowup in large state graphs and models the brain's attention filter. All nodes not reached above the threshold are not considered for re-render.
|
||||
|
||||
When a node is updated, its importance increases by 0.1 (capped at 1.0). Recently-changed state is more salient — it activates more easily in future propagation. This mirrors long-term potentiation: frequently-used pathways become lower-resistance.
|
||||
### 1.5 Importance Boost (Long-Term Potentiation Analog)
|
||||
|
||||
When a state node is updated, its `importance` increases by 0.1 (capped at 1.0):
|
||||
|
||||
```
|
||||
node.importance = Math.min(1.0, node.importance + 0.1)
|
||||
```
|
||||
|
||||
Recently-changed state becomes more salient — it activates more easily in future propagation. This mirrors long-term potentiation: frequently-used pathways become lower-resistance. State that changes often gains importance; state that never changes fades toward background salience.
|
||||
|
||||
### 1.6 Activation Surface
|
||||
|
||||
The **activation surface** is the set of nodes reached during a spreading activation pass, i.e., all nodes with `strength > PRUNE_THRESHOLD`. Components subscribed to any node in the activation surface receive update notifications and re-render.
|
||||
|
||||
---
|
||||
|
||||
## 2. Component Definition Syntax
|
||||
|
||||
Components are defined in `.el` files (the same extension as el source). A component is a specialized el module.
|
||||
Components are defined in `.el` files (shared extension with the El programming language). A component is a specialized el module with four optional sections.
|
||||
|
||||
### 2.1 Structure
|
||||
### 2.1 Component Structure
|
||||
|
||||
```
|
||||
component ComponentName {
|
||||
props {
|
||||
// optional prop declarations
|
||||
// prop declarations
|
||||
}
|
||||
|
||||
state {
|
||||
// optional state declarations
|
||||
// state declarations
|
||||
}
|
||||
|
||||
fn methodName(param: Type) -> ReturnType {
|
||||
@@ -83,26 +105,30 @@ component ComponentName {
|
||||
}
|
||||
```
|
||||
|
||||
All four sections (`props`, `state`, methods, `template`) are optional. Components with no template render an empty string.
|
||||
All four sections are optional. Components with no template render an empty string. Multiple `fn` blocks are allowed; multiple `props` or `state` blocks are not (only the last one is used).
|
||||
|
||||
### 2.2 Props
|
||||
|
||||
Props are inputs from the parent component. They are read-only inside the component.
|
||||
Props are read-only inputs from the parent component.
|
||||
|
||||
```
|
||||
props {
|
||||
label: String // required
|
||||
variant: String = "primary" // optional, with default
|
||||
label: String // required prop
|
||||
variant: String = "primary" // optional with default
|
||||
disabled: Bool = false // boolean with default
|
||||
onClick: Fn() -> Void // function prop
|
||||
}
|
||||
```
|
||||
|
||||
Prop types: `String`, `Int`, `Float`, `Bool`, `Fn(...) -> T`, `[T]`, `T?`.
|
||||
Supported prop types: `String`, `Int`, `Float`, `Bool`, `Fn(...) -> T`.
|
||||
|
||||
**Note:** Array (`[T]`) and optional (`T?`) type syntax is not currently parsed by the compiler. The parser reads a single identifier as the type name.
|
||||
|
||||
Props are accessed in generated code as `this._props_{name}` (not `this.props.name`). In method and template bodies, the compiler exposes each prop as a local constant named `{name}`.
|
||||
|
||||
### 2.3 State
|
||||
|
||||
State declarations create nodes in the component's Engram graph.
|
||||
State declarations create nodes in the component's Engram graph. Every state variable requires an initial value.
|
||||
|
||||
```
|
||||
state {
|
||||
@@ -113,20 +139,23 @@ state {
|
||||
```
|
||||
|
||||
Each state variable becomes:
|
||||
1. A node in `this._graph` (seeded at construction time)
|
||||
2. A reactive binding: changing the value via `setState()` triggers activation
|
||||
1. A node in `this._graph` (seeded at construction time with `type: 'state'`), tracked by ID in `this._stateNodes`.
|
||||
2. A mirrored value in `this._state` for direct read access.
|
||||
3. A reactive binding: changing the value via `setState()` triggers activation and re-render.
|
||||
|
||||
In method and template bodies, the compiler exposes each state variable as a local constant named `{name}` (reading from `this._state`).
|
||||
|
||||
### 2.4 Methods
|
||||
|
||||
Methods are `fn` definitions inside the component body. They have access to the component's current state and can call `setState()`.
|
||||
Methods are `fn` definitions inside the component body. State assignments in method bodies compile to `setState()` calls.
|
||||
|
||||
```
|
||||
fn increment() -> Void {
|
||||
count = count + 1
|
||||
count = count + 1 // compiles to: __self.setState('count', count + 1)
|
||||
}
|
||||
```
|
||||
|
||||
State assignments in method bodies (`count = count + 1`) are compiled to `setState('count', count + 1)` calls.
|
||||
The generated JavaScript method exposes all state variables as local `const` bindings before executing the body. The variable `__self` refers to the component instance.
|
||||
|
||||
---
|
||||
|
||||
@@ -134,7 +163,7 @@ State assignments in method bodies (`count = count + 1`) are compiled to `setSta
|
||||
|
||||
### 3.1 Interpolation
|
||||
|
||||
Embed any expression with `{expr}`:
|
||||
Any expression embedded in `{expr}`:
|
||||
|
||||
```
|
||||
<h1>{count}</h1>
|
||||
@@ -144,7 +173,7 @@ Embed any expression with `{expr}`:
|
||||
|
||||
### 3.2 Event Binding
|
||||
|
||||
Bind DOM events with `on:event={handler}`:
|
||||
DOM events bound with `on:event={handler}`:
|
||||
|
||||
```
|
||||
<button on:click={() => count = count + 1}>+</button>
|
||||
@@ -154,7 +183,7 @@ Bind DOM events with `on:event={handler}`:
|
||||
|
||||
Supported events: `click`, `input`, `change`, `mouseenter`, `mouseleave`, `keydown`, `keyup`, `keypress`, `focus`, `blur`, `submit`, `mousedown`, `mouseup`, `dblclick`, `contextmenu`.
|
||||
|
||||
The compiler emits `data-el-{event}` attributes. The renderer binds handlers at mount time and rebinds after each patch.
|
||||
The compiler emits `data-el-{event}` attributes on elements. The renderer binds handlers at mount time using `new Function(...)` evaluated in the component's scope (`__self`), and rebinds after each patch.
|
||||
|
||||
### 3.3 Dynamic Attributes
|
||||
|
||||
@@ -172,32 +201,30 @@ The compiler emits `data-el-{event}` attributes. The renderer binds handlers at
|
||||
|
||||
### 3.5 Boolean Attributes
|
||||
|
||||
The parser recognises a fixed set of boolean attribute names: `disabled`, `checked`, `readonly`, `required`, `multiple`, `selected`. These emit the attribute name when the expression is truthy, nothing when falsy.
|
||||
|
||||
```
|
||||
<button disabled={isDisabled}>Submit</button>
|
||||
<input checked={isChecked} />
|
||||
```
|
||||
|
||||
Boolean attributes emit the attribute name if the expression is truthy, nothing if falsy.
|
||||
A standalone attribute with no value (e.g., `<button disabled>`) is also parsed and treated as `BoolAttr { expr: "true" }`.
|
||||
|
||||
### 3.6 Component Usage
|
||||
### 3.6 Component Invocation
|
||||
|
||||
Components are invoked by their name (uppercase first letter). Props are passed as attributes:
|
||||
Uppercase-initial tags are el-ui components; lowercase tags are HTML elements:
|
||||
|
||||
```
|
||||
<Button label="Click me" variant="primary" onClick={() => handleClick()} />
|
||||
<Counter />
|
||||
<Counter initialValue={5} />
|
||||
<UserCard name={currentUser.name} />
|
||||
```
|
||||
|
||||
Lowercase tags are HTML elements. Uppercase tags are el-ui components.
|
||||
The compiler emits `${__self._child(ComponentClass, { prop: value })}` for component references. Child components share the parent's activation graph via `_child()`.
|
||||
|
||||
### 3.7 Conditional Rendering — `{#if}`
|
||||
|
||||
```
|
||||
{#if condition}
|
||||
<div>shown when true</div>
|
||||
{/if}
|
||||
|
||||
{#if loggedIn}
|
||||
<Dashboard />
|
||||
{:else}
|
||||
@@ -205,23 +232,19 @@ Lowercase tags are HTML elements. Uppercase tags are el-ui components.
|
||||
{/if}
|
||||
```
|
||||
|
||||
The condition is any JavaScript-compatible boolean expression.
|
||||
Compiles to a ternary template literal expression: `${(condition) ? `...` : `...`}`.
|
||||
|
||||
### 3.8 List Rendering — `{#each}`
|
||||
|
||||
```
|
||||
{#each items as item}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
|
||||
{#each users as user}
|
||||
<UserCard name={user.name} />
|
||||
{/each}
|
||||
```
|
||||
|
||||
The items expression must evaluate to an array.
|
||||
The `items` expression must evaluate to an array. Compiles to `${(items).map((user) => `...`).join('')}`.
|
||||
|
||||
### 3.9 Semantic Activation — `{#activate}` (The Novel Construct)
|
||||
### 3.9 Semantic Activation — `{#activate}` (Novel Construct)
|
||||
|
||||
```
|
||||
{#activate "recent premium subscribers" as results}
|
||||
@@ -229,350 +252,450 @@ The items expression must evaluate to an array.
|
||||
{/activate}
|
||||
```
|
||||
|
||||
This is el-ui's distinguishing feature. `{#activate}` runs a semantic query against the application's state graph at render time. The query string is a natural language description. The runtime:
|
||||
`{#activate}` runs a semantic query against the component's state graph at render time. The query string is a natural language description. The runtime:
|
||||
|
||||
1. Searches the graph for nodes whose content or name matches the query.
|
||||
2. Sorts results by their activation score (importance × text match score).
|
||||
3. Renders the template body once for each result, binding the result as `results`.
|
||||
1. Calls `this._graph.search("query")` which performs a string-based substring match on node `name` and `content` fields.
|
||||
2. Sorts results by score (combination of name/content match and node importance).
|
||||
3. Renders the template body once for each result, binding the result as the variable name.
|
||||
|
||||
In v0.1, the search is string-based. In future versions, it will use embedding vectors and cosine similarity — the same semantic search engine as the Engram knowledge database. This means state queries become conceptual rather than structural: instead of `items.filter(i => i.type === 'premium')`, you write `{#activate "premium subscribers"}` and the activation surface finds semantically matching nodes.
|
||||
Compiles to:
|
||||
|
||||
```javascript
|
||||
${((this._graph.search("query")) || []).map((results) => `...template...`).join('')}
|
||||
```
|
||||
|
||||
In v0.1, the search is string-based (substring match on `name` and `content.toString()`). In future versions connected to an Engram database, `{#activate}` will use embedding vectors and cosine similarity.
|
||||
|
||||
**Note on business logic protection:** The spec previously described AES-256-GCM encryption of `{#activate}` query strings in sealed production bundles. This feature is **not implemented** — there is no `--target prod` flag in the current CLI and no `el seal` command.
|
||||
|
||||
---
|
||||
|
||||
## 4. The State Graph
|
||||
## 4. State Graph
|
||||
|
||||
### 4.1 Graph Structure
|
||||
|
||||
Each component instance owns an Engram graph (`this._graph`). State nodes are seeded at construction time. The graph is shared between a component and its children (they operate on the same activation surface).
|
||||
Each component instance owns an Engram graph (`this._graph`). State nodes are seeded at construction. The graph is shared between a component and its children via `_child()` — they operate on the same activation surface.
|
||||
|
||||
### 4.2 Node Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `'state'` | Component state variable |
|
||||
| `'route'` | Router path node |
|
||||
| User-defined | Any custom node type via `this._graph.seed()` |
|
||||
|
||||
### 4.3 Seeding Nodes
|
||||
### 4.2 State Node Creation
|
||||
|
||||
```javascript
|
||||
const nodeId = graph.seed({ type: 'state', name: 'count', content: 0, importance: 0.5 });
|
||||
const nodeId = graph.seed({ type: 'state', name: 'count', content: 0 });
|
||||
// importance defaults to 0.5 if omitted
|
||||
```
|
||||
|
||||
Returns a UUID string that permanently identifies the node.
|
||||
|
||||
### 4.4 Connecting Nodes
|
||||
### 4.3 Edge Creation
|
||||
|
||||
```javascript
|
||||
graph.connect(fromId, toId, { weight: 0.8, relation: 'derived' });
|
||||
```
|
||||
|
||||
Connects two nodes with a directed edge. Higher weight = stronger activation path. Use this to encode semantic relationships between state (e.g., a derived value has an edge from its source).
|
||||
Connects two nodes with a directed edge. Higher weight = stronger activation path. Default weight is `1.0`, default relation is `'related'`.
|
||||
|
||||
### 4.5 Subscribing to Activation
|
||||
### 4.4 Node Retrieval
|
||||
|
||||
```javascript
|
||||
const node = graph.get(nodeId);
|
||||
// Returns: { id, type, name, content, importance, edges: [edgeId, ...] }
|
||||
```
|
||||
|
||||
Note: the runtime method is `graph.get(id)`, not `graph.getNode(id)`.
|
||||
|
||||
### 4.5 State Update
|
||||
|
||||
```javascript
|
||||
this.setState('count', newValue);
|
||||
// or directly:
|
||||
this._graph.update(nodeId, newValue);
|
||||
```
|
||||
|
||||
`setState` looks up the node by state variable name, calls `this._graph.update()` which:
|
||||
1. Updates `content`.
|
||||
2. Increments `importance` by 0.1 (capped at 1.0).
|
||||
3. Runs spreading activation from the changed node.
|
||||
4. Notifies all subscribers in the activation surface.
|
||||
5. Notifies direct subscribers on the changed node.
|
||||
|
||||
### 4.6 Activation Subscription
|
||||
|
||||
```javascript
|
||||
const unsubscribe = graph.subscribe(nodeId, (node) => {
|
||||
console.log('node activated:', node.content);
|
||||
// called whenever this node enters the activation surface
|
||||
});
|
||||
// Later:
|
||||
unsubscribe();
|
||||
unsubscribe(); // stop listening
|
||||
```
|
||||
|
||||
### 4.6 Semantic Search
|
||||
Components subscribe to their state nodes in the constructor. When spreading activation reaches a subscribed node, the subscriber callback fires and the component re-renders via `this._renderer.patch()`.
|
||||
|
||||
### 4.7 Semantic Search
|
||||
|
||||
```javascript
|
||||
const results = graph.search("recent items", "state");
|
||||
// Returns array of { id, name, content, importance, score } sorted by score
|
||||
// Returns: [{ id, type, name, content, importance, score }] sorted by score
|
||||
// nodeType argument is optional — filters by node.type if provided
|
||||
```
|
||||
|
||||
In v0.1, search is string-based: `name.includes(query)` or `content.toString().includes(query)`. Score is computed as `(nameMatch ? 0.6 : 0) + (contentMatch ? 0.4 : 0)` multiplied by `node.importance`.
|
||||
|
||||
### 4.8 Graph Dump (Debug)
|
||||
|
||||
```javascript
|
||||
const allNodes = graph.dump();
|
||||
// Returns: array of all node objects — useful for DevTools / debugging
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. The `{#activate}` Construct — Semantic State Queries
|
||||
## 5. Spreading Activation Implementation (In-Browser)
|
||||
|
||||
### 5.1 Philosophy
|
||||
|
||||
Traditional reactivity systems force you to structure your state queries around your storage schema. If you need "all premium users with recent activity", you write:
|
||||
The in-browser spreading activation algorithm in `graph.js` (and standalone utilities in `activation.js`) mirrors `engram-core/src/activation.rs`:
|
||||
|
||||
```javascript
|
||||
users.filter(u => u.isPremium && u.lastActive > threshold)
|
||||
// Within graph.activate(seedId, maxDepth = 3, pruneThreshold = 0.01)
|
||||
// Returns: Set<string> of activated node IDs
|
||||
|
||||
while (queue.length > 0) {
|
||||
// Best-first: process highest-strength candidate
|
||||
const { id, strength, depth } = bestInQueue();
|
||||
|
||||
if (depth >= maxDepth) continue;
|
||||
|
||||
for (const edgeId of node.edges) {
|
||||
const edge = this.edges.get(edgeId);
|
||||
const target = this.nodes.get(edge.to);
|
||||
|
||||
// Multiplicative formula
|
||||
const targetStrength = strength * edge.weight * Math.max(0, target.importance);
|
||||
|
||||
if (targetStrength <= pruneThreshold) continue;
|
||||
|
||||
// Winner-take-most
|
||||
if (targetStrength > prevBest) { /* enqueue */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is structural — it knows the shape of your data.
|
||||
**Key parameters:**
|
||||
- `maxDepth` default: **3** (not 4)
|
||||
- `pruneThreshold` default: **0.01**
|
||||
- `limit` default: **20** (applies to `spreadActivation()` utility; `graph.activate()` returns a Set with no limit)
|
||||
|
||||
el-ui's `{#activate}` is semantic — it knows the *meaning* of your query:
|
||||
**Correspondence with engram-core:**
|
||||
- Same best-first BFS traversal
|
||||
- Same multiplicative strength formula
|
||||
- Same pruning threshold semantics (0.01)
|
||||
- Same winner-take-most rule (strongest path to each node wins)
|
||||
- Same depth limit semantics
|
||||
|
||||
```
|
||||
{#activate "premium users with recent activity" as users}
|
||||
```
|
||||
The in-browser graph omits the `cosine_sim` factor because embedding vectors are not yet available in the browser.
|
||||
|
||||
The activation engine finds nodes that semantically match the query, regardless of their exact structure.
|
||||
|
||||
### 5.2 Compilation
|
||||
|
||||
`{#activate "query" as results}` compiles to:
|
||||
### 5.1 Standalone Activation Utilities (`activation.js`)
|
||||
|
||||
```javascript
|
||||
${(this._graph.search("query") || []).map((results) => `...template...`).join('')}
|
||||
import { spreadActivation, activationStrength, reachableNodes, PRUNE_THRESHOLD } from './el-ui.js';
|
||||
|
||||
// Multi-seed activation — returns sorted array of reached nodes (excluding seeds)
|
||||
const reached = spreadActivation(graph, [seedId1, seedId2], { maxDepth: 3, limit: 20 });
|
||||
// Returns: [{ nodeId, strength, hops, node }]
|
||||
|
||||
// Point-to-point strength — returns 0 if no path within maxDepth
|
||||
const strength = activationStrength(graph, fromId, toId, maxDepth);
|
||||
|
||||
// All reachable nodes from a seed (includes seed) — returns Set<string>
|
||||
const reachable = reachableNodes(graph, seedId, maxDepth);
|
||||
```
|
||||
|
||||
### 5.3 Future: Full Semantic Activation
|
||||
|
||||
In a future version connected to an Engram database, `{#activate}` will:
|
||||
1. Embed the query string into the same vector space as node embeddings.
|
||||
2. Seed activation at the closest semantic nodes.
|
||||
3. Spread outward via the graph's weighted edges.
|
||||
4. Return all nodes above the activation threshold.
|
||||
|
||||
This produces a truly associative query: finding nodes that are *semantically related* to the query, not just textually matching.
|
||||
|
||||
---
|
||||
|
||||
## 6. Router — Graph-Based Routing
|
||||
|
||||
### 6.1 Overview
|
||||
|
||||
Routes are nodes in the application graph. Navigation activates the target route node. Components subscribed to routing update automatically via spreading activation.
|
||||
Routes are nodes in the application graph of type `'route'`. Navigation activates the target route node. Components subscribed to routing update automatically via spreading activation.
|
||||
|
||||
### 6.2 Usage
|
||||
Route nodes start with `importance: 0.3`. Navigating to a route boosts its importance by `0.2` (capped at 1.0), modeling browser history salience.
|
||||
|
||||
```javascript
|
||||
import { Router, mount } from './el-ui.js';
|
||||
import { Home, About, NotFound } from './app.js';
|
||||
### 6.2 Route Node Connections
|
||||
|
||||
const graph = component._graph;
|
||||
const router = new Router(graph, {
|
||||
'/': Home,
|
||||
'/about': About,
|
||||
'*': NotFound,
|
||||
});
|
||||
The router connects parent routes to child routes via edges (weight: 0.8, relation: `'subroute'`). Navigating to `/about/team` activates the `/about` route node as well — activation spreads upward through the subroute chain.
|
||||
|
||||
// Navigate programmatically
|
||||
router.navigate('/about');
|
||||
|
||||
// Get current component
|
||||
const CurrentPage = router.currentComponent();
|
||||
|
||||
// Subscribe to route changes
|
||||
const unsub = router.subscribe((path) => console.log('navigated to', path));
|
||||
```
|
||||
|
||||
### 6.3 Route Node Connections
|
||||
|
||||
The router connects parent routes to child routes via edges (weight: 0.8, relation: `'subroute'`). Navigating to `/about/team` activates the `/about` route node as well (activation spreads upward through the subroute chain).
|
||||
|
||||
### 6.4 Path Matching
|
||||
### 6.3 Path Matching
|
||||
|
||||
1. Exact match: `/about` matches `/about`
|
||||
2. Prefix match (longest wins): `/about` matches `/about/team`
|
||||
3. Wildcard: `'*'` catches all unmatched paths
|
||||
|
||||
### 6.4 API
|
||||
|
||||
```javascript
|
||||
const router = new Router(graph, {
|
||||
'/': HomeComponent,
|
||||
'/about': AboutComponent,
|
||||
'*': NotFoundComponent,
|
||||
});
|
||||
|
||||
router.navigate('/about');
|
||||
router.navigate('/about', true); // replaceState instead of pushState
|
||||
const CurrentPage = router.currentComponent();
|
||||
const unsub = router.subscribe((path) => console.log('navigated to', path));
|
||||
const href = router.href('/about'); // returns the path string (identity in v0.1)
|
||||
```
|
||||
|
||||
The router also listens for `popstate` events (browser back/forward navigation) and activates the appropriate route node automatically.
|
||||
|
||||
---
|
||||
|
||||
## 7. Plugin API
|
||||
## 7. Compilation Pipeline
|
||||
|
||||
### 7.1 Custom Node Types
|
||||
### 7.1 Overview
|
||||
|
||||
Extend the graph with application-specific node types:
|
||||
```
|
||||
source.el → [Lexer] → [Parser] → [Codegen] → output
|
||||
```
|
||||
|
||||
The output format depends on the compilation target (see §7.4).
|
||||
|
||||
### 7.2 Lexer
|
||||
|
||||
The lexer (`src/lexer.rs`) is a single-pass O(n) tokenizer. It is context-sensitive: when it encounters the `template` keyword followed by `{`, it switches to template mode, producing template-specific tokens:
|
||||
|
||||
| Token | Description |
|
||||
|-------|-------------|
|
||||
| `HashIdent(kw)` | `{#if}`, `{#each}`, `{#activate}` — block open |
|
||||
| `SlashIdent(kw)` | `{/if}`, `{/each}`, `{/activate}` — block close |
|
||||
| `ColonIdent(kw)` | `{:else}` — block continuation |
|
||||
| `OnColon(event)` | `on:click`, `on:input` — event binding |
|
||||
| `SelfClose` | `/>` |
|
||||
| `CloseTag(name)` | `</div>` |
|
||||
| `RawText(s)` | Inline expression text, text content |
|
||||
|
||||
Non-template code tokens include keywords (`component`, `props`, `state`, `fn`, `template`, `if`, `else`, `return`), identifiers, literals (`String`, `Int`, `Float`, `Bool`), operators, and punctuation.
|
||||
|
||||
### 7.3 Parser
|
||||
|
||||
Hand-written recursive descent parser. Produces `Vec<Component>`. Each component is parsed as:
|
||||
|
||||
1. `component Name {` — component declaration
|
||||
2. `props { ... }` — prop definitions (optional)
|
||||
3. `state { ... }` — state definitions (optional)
|
||||
4. `fn method(...) -> Type { ... }` — methods (optional, multiple allowed)
|
||||
5. `template { ... }` — template tree (optional)
|
||||
|
||||
The template parses as a tree of `TemplateNode` values:
|
||||
|
||||
| Variant | Description |
|
||||
|---------|-------------|
|
||||
| `Element { tag, attrs, children }` | HTML element with attributes and children |
|
||||
| `Component { name, props }` | Uppercase-initial component reference |
|
||||
| `Text(s)` | Literal text content |
|
||||
| `Interpolation(expr)` | `{expr}` — expression interpolation |
|
||||
| `If { condition, then, else_ }` | `{#if}...{:else}...{/if}` |
|
||||
| `Each { items, item_name, children }` | `{#each items as item}...{/each}` |
|
||||
| `Activate { query, result_name, children }` | `{#activate "query" as name}...{/activate}` |
|
||||
|
||||
Method bodies are captured as raw source text and passed through to the code generator with simple string transformations.
|
||||
|
||||
**Unknown `{#blockname}` tags** produce a parse error (`unknown block tag: #name`).
|
||||
|
||||
### 7.4 Code Generator — Multi-Target
|
||||
|
||||
The code generator (`src/codegen.rs`) supports three compilation targets:
|
||||
|
||||
#### `CodegenTarget::Web` (default) — Legacy JavaScript
|
||||
|
||||
Emits an ES2022 JavaScript module using the el-ui JS runtime (`graph.js`, `renderer.js`, `router.js`, `index.js`). This is the primary target for browser-based applications today.
|
||||
|
||||
For each component:
|
||||
1. Emits a class extending `Component` from the runtime.
|
||||
2. Constructor: seeds state nodes into `this._graph`, sets prop values as `this._props_{name}`, subscribes to activation events.
|
||||
3. Emits `setState(name, value)` which calls `this._graph.update()` to trigger activation.
|
||||
4. Emits `render()` returning a template literal string.
|
||||
5. Translates state assignments: `count = count + 1` → `__self.setState('count', count + 1)`.
|
||||
6. Translates template interpolations: `{count}` → `${count }`.
|
||||
7. Emits event handlers as `data-el-{event}` attributes for the renderer to bind.
|
||||
8. Emits `${__self._child(ComponentClass, props)}` for component references.
|
||||
|
||||
#### `CodegenTarget::Server` — Rust SSR
|
||||
|
||||
Emits a Rust module where each component is a struct implementing `render_to_html(&self) -> String` via `el_platform::ServerBackend`.
|
||||
|
||||
**Note:** The `build_node_tree()` implementation in this target is a stub — it returns a placeholder `PlatformNode::element("div")`. Full template-to-PlatformNode codegen is not yet implemented for the server target.
|
||||
|
||||
#### `CodegenTarget::Native(Platform)` — Rust Native / WASM
|
||||
|
||||
Emits a Rust module where each component builds a `PlatformNode` tree via the semantic primitive layer and mounts it via the `el_platform` backend for the given platform.
|
||||
|
||||
Supported platforms:
|
||||
|
||||
| Platform | Backend | Output format |
|
||||
|----------|---------|---------------|
|
||||
| `Platform::Web` | `WebBackend` (web-sys) | Rust compiled to WASM — NOT JavaScript |
|
||||
| `Platform::Ios` | `IosBackend` | Rust (objc2-ui-kit) |
|
||||
| `Platform::Android` | `AndroidBackend` | Kotlin (Jetpack Compose via build.rs) |
|
||||
| `Platform::Macos` | `MacosBackend` | Rust (objc2-app-kit) |
|
||||
| `Platform::Linux` | `LinuxBackend` | Rust (gtk4-rs) |
|
||||
| `Platform::Windows` | `WindowsBackend` | Rust (windows-rs / WinUI 3) |
|
||||
|
||||
**Note:** The `build_node_tree()` implementation in all native targets is currently a stub returning `PlatformNode::element("div")`. Full AST-to-semantic-primitive lowering is not yet wired in — the semantic module and per-platform codegen functions exist and are correct, but the compiler does not yet call them from the AST walk.
|
||||
|
||||
### 7.5 Semantic Primitive Layer (`semantic.rs`)
|
||||
|
||||
The semantic layer is the bridge between the template AST and platform-specific code generation. It defines EBD-driven concepts — *what* something is, not *how* it looks:
|
||||
|
||||
**AppearanceConcept** — what a control is:
|
||||
- `Action` — primary action trigger (blue/filled on most platforms)
|
||||
- `Destructive` — dangerous/irreversible action (red tint)
|
||||
- `Secondary` — subdued / less-prominent action
|
||||
- `Navigation` — link-like / back-navigation element
|
||||
- `Informational` — read-only display element
|
||||
- `Structural` — layout container with no interactive meaning
|
||||
|
||||
**LayoutConcept** — how children are arranged:
|
||||
- `Stack` — vertical (Column / VStack / GtkBox vertical)
|
||||
- `Row` — horizontal (Row / HStack / GtkBox horizontal)
|
||||
- `Grid` — two-dimensional grid
|
||||
- `Overlay` — z-axis layering (ZStack / GtkOverlay)
|
||||
- `Scroll` — scrollable container
|
||||
|
||||
**TextConcept** — the semantic role of text:
|
||||
- `Heading { level: u8 }` — heading at level 1–6
|
||||
- `Body` — default body text
|
||||
- `Caption` — small secondary text
|
||||
- `Label` — control label
|
||||
- `Code` — monospaced code text
|
||||
|
||||
**SemanticPrimitive** — the EBD building blocks:
|
||||
- `Button { label, appearance, on_press }` — interactive button
|
||||
- `Text { content, concept }` — text display
|
||||
- `Container { children, layout }` — layout container
|
||||
- `Input { binding, hint, appearance }` — text input bound to state
|
||||
- `Image { src, alt }` — image element
|
||||
- `Toggle { binding, label }` — checkbox/switch bound to boolean state
|
||||
- `List { items_binding, item_name, item_template }` — homogeneous list
|
||||
|
||||
Appearance is inferred from explicit `appearance="..."` attributes, CSS class names, or HTML tag heuristics via `infer_appearance()`. Layout and text concepts are inferred from tag names via `infer_layout_concept()` and `infer_text_concept()`.
|
||||
|
||||
### 7.6 CLI
|
||||
|
||||
```bash
|
||||
el-ui-compiler App.el # compiles to App.js (Web target, default)
|
||||
el-ui-compiler App.el -o app.js # explicit output path
|
||||
```
|
||||
|
||||
The CLI always uses `CodegenTarget::Web`. Target selection (`--target server`, `--target ios`, etc.) is available as a `CodegenTarget` enum in the library API but is not exposed as a CLI flag in the current implementation.
|
||||
|
||||
---
|
||||
|
||||
## 8. Runtime Architecture
|
||||
|
||||
### 8.1 Module Structure
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `graph.js` | Engram graph: nodes, edges, activation, search, subscribe, dump |
|
||||
| `activation.js` | Standalone activation utilities: `spreadActivation`, `activationStrength`, `reachableNodes` |
|
||||
| `renderer.js` | DOM patching, event binding |
|
||||
| `router.js` | Graph-based routing |
|
||||
| `index.js` | `Component` base class, `mount()`, re-exports |
|
||||
|
||||
### 8.2 Component Lifecycle
|
||||
|
||||
| Lifecycle event | Description |
|
||||
|-----------------|-------------|
|
||||
| `constructor(props)` | Seeds state graph, subscribes to activation |
|
||||
| `onMount()` | Called after first render and DOM attachment |
|
||||
| `render()` | Returns HTML string; called on activation |
|
||||
|
||||
**Note:** `onDestroy()` is documented in prior drafts but is not implemented in the base `Component` class or the `Renderer`. It will not be called by the runtime.
|
||||
|
||||
### 8.3 Mounting
|
||||
|
||||
```javascript
|
||||
import { mount } from './el-ui.js';
|
||||
import { App } from './app.js';
|
||||
|
||||
const component = mount(App, '#app');
|
||||
|
||||
// Add a custom "user session" node
|
||||
const sessionNodeId = component._graph.seed({
|
||||
type: 'session',
|
||||
name: 'currentUser',
|
||||
content: { id: '123', name: 'Alice' },
|
||||
importance: 0.9,
|
||||
});
|
||||
|
||||
// Connect it to a state node for reactive updates
|
||||
component._graph.connect(sessionNodeId, component._stateNodes['userId'], {
|
||||
weight: 1.0,
|
||||
relation: 'owns',
|
||||
});
|
||||
const component = mount(App, '#app', { optionalProp: 'value' });
|
||||
// mount() returns the live Component instance
|
||||
```
|
||||
|
||||
### 7.2 Custom Activation Sources
|
||||
`mount` instantiates the root component, creates a `Renderer`, calls `renderer.mount()` which calls `render()`, sets `root.innerHTML`, binds events, then calls `onMount()`.
|
||||
|
||||
Trigger activation from any node, not just state updates:
|
||||
### 8.4 DOM Patching Strategy (v0.1)
|
||||
|
||||
```javascript
|
||||
import { spreadActivation } from './el-ui.js';
|
||||
The renderer uses full string re-render on every state change: `root.innerHTML = component.render()`. The activated node set argument to `patch()` is accepted but unused — targeted patching is planned for v0.2.
|
||||
|
||||
// Run a custom activation query
|
||||
const results = spreadActivation(graph, [myNodeId], {
|
||||
maxDepth: 4,
|
||||
limit: 10,
|
||||
pruneThreshold: 0.05,
|
||||
});
|
||||
After patching, the renderer attempts to restore focus to the previously focused element by `id`.
|
||||
|
||||
results.forEach(({ nodeId, strength, node }) => {
|
||||
console.log(`${node.name}: ${strength.toFixed(3)}`);
|
||||
});
|
||||
```
|
||||
Event handlers are rebound after every patch by scanning the new DOM for `data-el-{event}` attributes. Handlers are compiled via `new Function('__self', ...)` — this requires a permissive Content Security Policy in v0.1.
|
||||
|
||||
### 7.3 Component Lifecycle Extension
|
||||
---
|
||||
|
||||
```javascript
|
||||
class MyComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// Seed custom nodes
|
||||
this._externalData = this._graph.seed({
|
||||
type: 'external',
|
||||
name: 'fetchedData',
|
||||
content: null,
|
||||
importance: 0.7,
|
||||
});
|
||||
}
|
||||
## 9. Platform Backend Architecture (`el-platform`)
|
||||
|
||||
onMount() {
|
||||
// Side effects after first render
|
||||
fetch('/api/data')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this._graph.update(this._externalData, data);
|
||||
});
|
||||
}
|
||||
The `el-platform` crate defines the `PlatformBackend` trait that all rendering backends implement:
|
||||
|
||||
render() {
|
||||
const data = this._graph.get(this._externalData)?.content;
|
||||
return data ? `<div>${data.title}</div>` : `<div>Loading...</div>`;
|
||||
}
|
||||
```rust
|
||||
pub trait PlatformBackend: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode>;
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode>;
|
||||
fn set_attribute(&self, node: &mut PlatformNode, name: &str, value: &str) -> PlatformResult<()>;
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()>;
|
||||
fn append_child(&self, parent: &mut PlatformNode, child: PlatformNode) -> PlatformResult<()>;
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()>;
|
||||
fn replace_child(&self, parent: &mut PlatformNode, index: usize, new_child: PlatformNode) -> PlatformResult<()>;
|
||||
fn bind_event(&self, node: &mut PlatformNode, event: &str, handler: EventHandler) -> PlatformResult<()>;
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String>;
|
||||
fn mount(&self, root: PlatformNode, container_id: &str) -> PlatformResult<()>;
|
||||
fn patch(&self, old: &PlatformNode, new: &PlatformNode) -> PlatformResult<()>;
|
||||
fn supports_ssr(&self) -> bool { false }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
The `ServerBackend` is the only backend where `supports_ssr()` returns `true` and `render_to_string()` produces real HTML output. All other backends are platform-native.
|
||||
|
||||
## 8. Compilation Pipeline
|
||||
Platform is selected via `manifest.el`:
|
||||
|
||||
### 8.1 Overview
|
||||
|
||||
```
|
||||
source.el → [lexer] → [parser] → [codegen] → output.js
|
||||
```
|
||||
|
||||
### 8.2 Lexer (`crates/el-ui-compiler/src/lexer.rs`)
|
||||
|
||||
The lexer is a single-pass, O(n) tokenizer. It is context-sensitive: when it encounters the `template` keyword followed by `{`, it switches to template mode, producing template-specific tokens:
|
||||
|
||||
| Token | Description |
|
||||
|-------|-------------|
|
||||
| `HashIdent(kw)` | `{#if}`, `{#each}`, `{#activate}` |
|
||||
| `SlashIdent(kw)` | `{/if}`, `{/each}`, `{/activate}` |
|
||||
| `ColonIdent(kw)` | `{:else}` |
|
||||
| `OnColon(event)` | `on:click`, `on:input`, etc. |
|
||||
| `SelfClose` | `/>` |
|
||||
| `CloseTag(name)` | `</div>` |
|
||||
| `RawText(s)` | Inline expression text, text content |
|
||||
|
||||
### 8.3 Parser (`crates/el-ui-compiler/src/parser.rs`)
|
||||
|
||||
Hand-written recursive descent. Produces `Vec<Component>`. Each component is parsed as:
|
||||
|
||||
1. `component Name {` — component declaration
|
||||
2. `props { ... }` — prop definitions (optional)
|
||||
3. `state { ... }` — state definitions (optional)
|
||||
4. `fn method(...) -> Type { ... }` — methods (optional, multiple)
|
||||
5. `template { ... }` — template tree (optional)
|
||||
|
||||
The template is parsed as a tree of `TemplateNode` values. HTML elements, component references, interpolations, and block directives are all represented as nodes in this tree.
|
||||
|
||||
### 8.4 Code Generator (`crates/el-ui-compiler/src/codegen.rs`)
|
||||
|
||||
The code generator transforms the AST into a JavaScript ES2022 module. For each component:
|
||||
|
||||
1. Emits a class extending `Component` from the runtime.
|
||||
2. In the constructor: seeds state nodes into `this._graph`, subscribes to activation events.
|
||||
3. Emits `setState()`, which calls `this._graph.update()` to trigger activation.
|
||||
4. Emits a `render()` method returning a template literal string.
|
||||
5. Translates state assignments in methods: `count = count + 1` → `this.setState('count', count + 1)`.
|
||||
6. Translates template interpolations: `{count}` → `${count}`.
|
||||
7. Emits event handlers as `data-el-{event}` attributes for the renderer to bind.
|
||||
|
||||
### 8.5 CLI
|
||||
|
||||
```bash
|
||||
# Compile a single .el file
|
||||
el-ui-compiler App.el -o app.js
|
||||
|
||||
# Default output: same name, .js extension
|
||||
el-ui-compiler App.el # produces App.js
|
||||
```toml
|
||||
[platform]
|
||||
target = "web" # web | server | ios | android | macos | linux | windows
|
||||
ssr = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Production Build — Quantum-Sealed via engram-crypto
|
||||
## 10. Comparison to Other Frameworks
|
||||
|
||||
The production build pipeline follows the el sealed artifact format:
|
||||
### 10.1 vs. React
|
||||
|
||||
```bash
|
||||
# 1. Compile .el to .js
|
||||
el-ui-compiler App.el -o app.js --target prod
|
||||
React uses virtual DOM diffing: on every render, it constructs a virtual tree and diffs it against the previous tree to identify minimal DOM mutations. The unit of re-render is the component; re-render triggers are driven by `setState`, `useReducer`, or context changes.
|
||||
|
||||
# 2. Seal the JavaScript bundle
|
||||
ENGRAM_SEAL_KEY=my-deploy-key el seal app.js -o app.sealed
|
||||
```
|
||||
el-ui does not compute a virtual DOM. The spreading activation pass determines the re-render set from the graph topology. State relationships that exist in the graph are automatically propagated; relationships that do not exist are not. There is no need to declare dependencies, use `useMemo`, or prevent unnecessary renders with `React.memo` — the graph structure encodes the dependency information directly.
|
||||
|
||||
The sealed artifact is an `ENGRAM01` sealed bundle (same format as the el production target):
|
||||
### 10.2 vs. Vue
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
────── ────── ─────────────────────────────────────────
|
||||
0 8 Magic: b"ENGRAM01"
|
||||
8 2 Format version: u16 big-endian (currently 1)
|
||||
10 * JSON: { algorithm_id, signature, encapsulated_key, nonce, ciphertext }
|
||||
```
|
||||
Vue uses a Proxy-based reactive system: state values are wrapped in reactive proxies, and reads from these proxies during render are tracked as dependencies. When a reactive value changes, all components that read it during their last render are re-rendered.
|
||||
|
||||
AES-256-GCM encryption. The key is derived from the deployment binding (environment variable, machine fingerprint, or none). Without the key, the bundle is indistinguishable from random bytes. No static analysis tool can extract the application logic, queries, or API keys from a sealed bundle.
|
||||
el-ui's graph model is explicit: relationships between state nodes are declared via graph edges, not inferred from read tracking. This makes dependencies inspectable and modifiable at runtime. It also enables semantic queries (`{#activate}`) that find relevant state by meaning rather than by structural reference.
|
||||
|
||||
**Why "quantum-sealed":** AES-256 is quantum-resistant at 128-bit quantum security (Grover's algorithm provides only a quadratic speedup). The `algorithm_id` field is forward-compatible with ML-KEM when it stabilizes.
|
||||
### 10.3 vs. Svelte
|
||||
|
||||
### 9.1 The `{#activate}` Query is Protected
|
||||
Svelte uses compile-time analysis to determine which parts of the DOM update when which state changes. The compiler emits targeted DOM mutations. There is no runtime overhead for a virtual DOM or a reactive system.
|
||||
|
||||
A key benefit of production sealing: `{#activate}` query strings are embedded in the compiled bundle and encrypted with the application logic. Proprietary semantic queries (which encode business logic about how your application understands its data) are invisible to competitors who decompile your application.
|
||||
el-ui uses a runtime spreading activation pass. This is more expensive than Svelte's compile-time approach for small, known-static state graphs. The advantage is dynamic state topology: new nodes and edges can be added at runtime without recompilation, enabling state graphs that evolve with program execution.
|
||||
|
||||
### 10.4 Core Differentiator
|
||||
|
||||
No prior framework uses Hebbian spreading activation as the re-render decision mechanism. The multiplicative activation formula, the self-seeded activation from a root node, the importance-boost on state change (LTP analog), and the semantic `{#activate}` query construct have no analogues in any shipping frontend framework as of April 2026.
|
||||
|
||||
---
|
||||
|
||||
## 10. Runtime Size Target
|
||||
## 11. Versioning Roadmap
|
||||
|
||||
The `dist/el-ui.js` runtime targets **under 15KB minified and gzipped**. As of v0.1:
|
||||
|
||||
| Module | Purpose | ~Size |
|
||||
|--------|---------|-------|
|
||||
| `graph.js` | Engram graph (nodes, edges, activation, search, subscribe) | ~3KB |
|
||||
| `activation.js` | Standalone activation utilities | ~1.5KB |
|
||||
| `renderer.js` | DOM patching, event binding | ~2KB |
|
||||
| `router.js` | Graph-based routing | ~1.5KB |
|
||||
| `index.js` | Component base class, `mount()`, re-exports | ~1KB |
|
||||
|
||||
Total: ~9KB source, ~4KB minified+gzipped (estimated).
|
||||
|
||||
---
|
||||
|
||||
## 11. Versioning and Compatibility
|
||||
|
||||
el-ui follows semantic versioning.
|
||||
|
||||
- **v0.1.x** — Initial release. Full re-render on state change. String-based `{#activate}` search.
|
||||
- **v0.2.x** — Targeted DOM patching (patch only nodes in the activation surface). `{#activate}` with embedding-based semantic search.
|
||||
- **v0.3.x** — ML-KEM sealed artifacts. Engram database integration for compile-time semantic type checking of `{#activate}` queries.
|
||||
- **v1.0.0** — Stable API. Full production sealing. LSP integration with spreading activation autocomplete.
|
||||
|
||||
---
|
||||
|
||||
## 12. Relationship to el
|
||||
|
||||
el-ui `.el` files share the `.el` extension with el source files. Components are specialized el modules — in a future version, an `.el` file can mix component definitions with el type definitions, constants, and utility functions in a single compilation unit.
|
||||
|
||||
The spreading activation algorithm in `graph.js` and `activation.js` faithfully mirrors `engram-core/src/activation.rs`:
|
||||
- Same BFS-based traversal
|
||||
- Same multiplicative strength formula
|
||||
- Same pruning threshold semantics
|
||||
- Same winner-take-most rule (strongest path to each node wins)
|
||||
|
||||
The in-browser graph is a lightweight implementation without Engram's full embedding vector machinery. In production, a WASM-compiled Engram core can replace the JavaScript graph entirely, enabling true semantic activation with cosine similarity over embedding vectors.
|
||||
| Version | Key changes |
|
||||
|---------|-------------|
|
||||
| v0.1.x | Current. Full re-render on state change. String-based `{#activate}` search. JS (Web) target only via CLI. Native targets available via library API but `build_node_tree()` is stub. |
|
||||
| v0.2.x | Targeted DOM patching (only nodes in activation surface). `{#activate}` with embedding-based semantic search. CLI `--target` flag for native targets. Full AST→semantic→PlatformNode lowering. |
|
||||
| v0.3.x | WASM-compiled Engram core replacing JavaScript graph. Sealed artifact support. `onDestroy()` lifecycle. |
|
||||
| v1.0.0 | Stable API. Full production sealing. LSP integration with spreading activation autocomplete. |
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// el-aop — Aspect-Oriented Programming for el-ui.
|
||||
//
|
||||
// Cross-cutting concerns as first-class language features. Decorators applied
|
||||
// to components and methods. @authenticate is the default; @public is the
|
||||
// explicit opt-out.
|
||||
|
||||
vessel "el-aop" {
|
||||
version "0.1.0"
|
||||
description "Decorators: @authenticate, @authorize, @cache, @rate_limit, ..."
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
el-auth "0.1"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// el-aop — Aspect-Oriented Programming for el-ui.
|
||||
//
|
||||
// Each aspect has three advice points: before, after, around. The aspect
|
||||
// chain is composed at compile time by the el-ui-compiler from decorators.
|
||||
//
|
||||
// The El runtime model uses tagged JSON for InvocationContext so an aspect
|
||||
// chain composes purely by passing the context map through each advice fn.
|
||||
//
|
||||
// Built-in aspects:
|
||||
// @authenticate — defaults on every component (security-by-default)
|
||||
// @public — opt-out marker
|
||||
// @authorize — role/permission gate
|
||||
// @cache — TTL-keyed memoization
|
||||
// @rate_limit — per-principal token bucket
|
||||
// @retry — retry on error with backoff
|
||||
// @log — structured log around invocation
|
||||
// @trace — emit OpenTelemetry-shaped spans
|
||||
// @validate — JSON schema check on args
|
||||
|
||||
// ── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let AOP_ERR_UNAUTHENTICATED: String = "aop.unauthenticated"
|
||||
let AOP_ERR_FORBIDDEN: String = "aop.forbidden"
|
||||
let AOP_ERR_RATE_LIMITED: String = "aop.rate_limited"
|
||||
let AOP_ERR_VALIDATION: String = "aop.validation_failed"
|
||||
let AOP_ERR_RETRIES_EXHAUSTED: String = "aop.retries_exhausted"
|
||||
|
||||
// ── Invocation context ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Stored as JSON: { target, method, args:{}, metadata:{} }
|
||||
// All advice mutates by returning a new ctx (functional style).
|
||||
|
||||
fn ctx_new(target: String, method: String) -> String {
|
||||
"{\"target\":\"" + target + "\",\"method\":\"" + method
|
||||
+ "\",\"args\":{},\"metadata\":{}}"
|
||||
}
|
||||
|
||||
fn ctx_with_arg(ctx: String, key: String, value: String) -> String {
|
||||
json_set_path(ctx, "args." + key, "\"" + value + "\"")
|
||||
}
|
||||
|
||||
fn ctx_with_meta(ctx: String, key: String, value: String) -> String {
|
||||
json_set_path(ctx, "metadata." + key, "\"" + value + "\"")
|
||||
}
|
||||
|
||||
fn ctx_get_meta(ctx: String, key: String) -> String {
|
||||
json_get_path(ctx, "metadata." + key)
|
||||
}
|
||||
|
||||
// ── Aspect dispatch table ────────────────────────────────────────────────────
|
||||
//
|
||||
// Each aspect is a triple of fn names: (before, around, after). The registry
|
||||
// is a map from aspect name -> { before, around, after } JSON.
|
||||
|
||||
fn registry_new() -> String {
|
||||
"{}"
|
||||
}
|
||||
|
||||
fn registry_register(reg: String, name: String, before_fn: String, around_fn: String, after_fn: String) -> String {
|
||||
let entry: String = "{\"before\":\"" + before_fn + "\",\"around\":\"" + around_fn
|
||||
+ "\",\"after\":\"" + after_fn + "\"}"
|
||||
json_set(reg, name, entry)
|
||||
}
|
||||
|
||||
// ── @authenticate — applied by default ───────────────────────────────────────
|
||||
|
||||
fn aspect_authenticate_before(ctx: String) -> String {
|
||||
let token: String = ctx_get_meta(ctx, "authorization")
|
||||
if str_eq(token, "") {
|
||||
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
|
||||
}
|
||||
let secret: String = env("JWT_SECRET")
|
||||
let auth_ctx: String = auth_middleware(token, secret)
|
||||
let user_id: String = json_get(auth_ctx, "user_id")
|
||||
if str_eq(user_id, "") {
|
||||
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
|
||||
}
|
||||
ctx_with_meta(ctx, "user_id", user_id)
|
||||
}
|
||||
|
||||
// ── @public — explicit opt-out marker ────────────────────────────────────────
|
||||
|
||||
fn aspect_public_before(ctx: String) -> String {
|
||||
ctx_with_meta(ctx, "public", "true")
|
||||
}
|
||||
|
||||
// ── @authorize(role) ─────────────────────────────────────────────────────────
|
||||
|
||||
fn aspect_authorize_before(ctx: String, required_role: String) -> String {
|
||||
let user_id: String = ctx_get_meta(ctx, "user_id")
|
||||
if str_eq(user_id, "") {
|
||||
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
|
||||
}
|
||||
let roles_json: String = engram_edge_traverse(user_id, "has_role")
|
||||
if !str_contains(roles_json, "\"" + required_role + "\"") {
|
||||
return ctx_with_meta(ctx, "error", AOP_ERR_FORBIDDEN)
|
||||
}
|
||||
ctx
|
||||
}
|
||||
|
||||
// ── @cache(ttl_seconds) ──────────────────────────────────────────────────────
|
||||
|
||||
fn aspect_cache_around(ctx: String, ttl: Int, proceed: String) -> String {
|
||||
let key: String = ctx_get_meta(ctx, "cache_key")
|
||||
if str_eq(key, "") {
|
||||
let target: String = json_get(ctx, "target")
|
||||
let method: String = json_get(ctx, "method")
|
||||
let args: String = json_get(ctx, "args")
|
||||
let key = sha256_hex(target + ":" + method + ":" + args)
|
||||
}
|
||||
let cached: String = cache_get(key)
|
||||
if !str_eq(cached, "") {
|
||||
return ctx_with_meta(ctx, "result", cached)
|
||||
}
|
||||
// Caller invokes proceed(ctx) externally; we record the key for `after` to use.
|
||||
ctx_with_meta(ctx, "cache_key", key)
|
||||
}
|
||||
|
||||
fn aspect_cache_after(ctx: String, result: String, ttl: Int) -> String {
|
||||
let key: String = ctx_get_meta(ctx, "cache_key")
|
||||
if !str_eq(key, "") { cache_put(key, result, ttl) }
|
||||
result
|
||||
}
|
||||
|
||||
// ── @rate_limit(requests, per_seconds) ───────────────────────────────────────
|
||||
|
||||
fn aspect_rate_limit_before(ctx: String, requests: Int, per_seconds: Int) -> String {
|
||||
let principal: String = ctx_get_meta(ctx, "user_id")
|
||||
if str_eq(principal, "") { let principal = ctx_get_meta(ctx, "ip") }
|
||||
let bucket_key: String = "rl:" + json_get(ctx, "target") + ":" + principal
|
||||
let allowed: Bool = rate_bucket_take(bucket_key, requests, per_seconds)
|
||||
if !allowed { return ctx_with_meta(ctx, "error", AOP_ERR_RATE_LIMITED) }
|
||||
ctx
|
||||
}
|
||||
|
||||
// ── @retry(attempts, backoff_ms) ────────────────────────────────────────────
|
||||
//
|
||||
// retry is necessarily an `around` aspect — it must own the loop.
|
||||
|
||||
fn aspect_retry_around(ctx: String, attempts: Int, backoff_ms: Int, proceed_fn_name: String) -> String {
|
||||
let i: Int = 0
|
||||
let result: String = ""
|
||||
while i < attempts {
|
||||
let result = call_dynamic(proceed_fn_name, ctx)
|
||||
let err: String = json_get(result, "error")
|
||||
if str_eq(err, "") { return result }
|
||||
sleep_ms(backoff_ms * (i + 1))
|
||||
let i = i + 1
|
||||
}
|
||||
ctx_with_meta(ctx, "error", AOP_ERR_RETRIES_EXHAUSTED)
|
||||
}
|
||||
|
||||
// ── @log / @trace ────────────────────────────────────────────────────────────
|
||||
|
||||
fn aspect_log_before(ctx: String) -> String {
|
||||
println("[aop] -> " + json_get(ctx, "target") + "." + json_get(ctx, "method"))
|
||||
ctx
|
||||
}
|
||||
|
||||
fn aspect_log_after(ctx: String, result: String) -> String {
|
||||
println("[aop] <- " + json_get(ctx, "target") + "." + json_get(ctx, "method"))
|
||||
result
|
||||
}
|
||||
|
||||
fn aspect_trace_before(ctx: String) -> String {
|
||||
let span_id: String = uuid_v4()
|
||||
ctx_with_meta(ctx, "span_id", span_id)
|
||||
}
|
||||
|
||||
// ── @validate(schema) ────────────────────────────────────────────────────────
|
||||
|
||||
fn aspect_validate_before(ctx: String, schema_json: String) -> String {
|
||||
let args: String = json_get(ctx, "args")
|
||||
let valid: Bool = json_schema_check(args, schema_json)
|
||||
if !valid { return ctx_with_meta(ctx, "error", AOP_ERR_VALIDATION) }
|
||||
ctx
|
||||
}
|
||||
|
||||
// ── Aspect chain composition ─────────────────────────────────────────────────
|
||||
//
|
||||
// The compiler emits a call sequence like:
|
||||
// ctx = ctx_new(...)
|
||||
// ctx = aspect_authenticate_before(ctx)
|
||||
// ctx = aspect_log_before(ctx)
|
||||
// result = proceed(ctx)
|
||||
// result = aspect_log_after(ctx, result)
|
||||
// At runtime an explicit `chain_run` exists for dynamic composition.
|
||||
|
||||
fn chain_run(ctx: String, before_fns: String, around_fn: String, after_fns: String, proceed_fn: String) -> String {
|
||||
let cur_ctx: String = ctx
|
||||
// before chain
|
||||
let i: Int = 0
|
||||
let befs: String = before_fns
|
||||
while !str_eq(befs, "") {
|
||||
let comma: Int = str_index_of(befs, ",")
|
||||
let fn_name: String = befs
|
||||
if comma > 0 { let fn_name = str_slice(befs, 0, comma) }
|
||||
let cur_ctx = call_dynamic(fn_name, cur_ctx)
|
||||
let err: String = ctx_get_meta(cur_ctx, "error")
|
||||
if !str_eq(err, "") { return cur_ctx }
|
||||
if comma > 0 { let befs = str_slice(befs, comma + 1, str_len(befs)) }
|
||||
if comma < 0 { let befs = "" }
|
||||
}
|
||||
// around / proceed
|
||||
let result: String = call_dynamic(proceed_fn, cur_ctx)
|
||||
// after chain (right-to-left composition; simplified left-to-right here)
|
||||
let afts: String = after_fns
|
||||
while !str_eq(afts, "") {
|
||||
let comma: Int = str_index_of(afts, ",")
|
||||
let fn_name: String = afts
|
||||
if comma > 0 { let fn_name = str_slice(afts, 0, comma) }
|
||||
let result = call_dynamic2(fn_name, cur_ctx, result)
|
||||
if comma > 0 { let afts = str_slice(afts, comma + 1, str_len(afts)) }
|
||||
if comma < 0 { let afts = "" }
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ───────────────────────────────────────────────────────
|
||||
|
||||
let ctx: String = ctx_new("ProfilePage", "render")
|
||||
let ctx = ctx_with_arg(ctx, "user_id", "u-001")
|
||||
println("[el-aop] ctx = " + ctx)
|
||||
@@ -0,0 +1,22 @@
|
||||
// el-auth — Built-in authentication and authorization for el-ui.
|
||||
//
|
||||
// Not a library you add. Native to the framework. Sits on top of el-identity:
|
||||
// el-identity owns the graph (users, sessions, OAuth tokens); el-auth owns the
|
||||
// request-time enforcement (verify token, check permission, enforce roles).
|
||||
|
||||
vessel "el-auth" {
|
||||
version "0.1.0"
|
||||
description "Built-in auth/authz: JWT, sessions, RBAC, middleware"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
el-identity "0.1"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// el-auth — Built-in authentication and authorization for el-ui.
|
||||
//
|
||||
// Engram-aware sessions: server-side invalidation works even with stateless
|
||||
// JWTs because every session is also a graph node.
|
||||
//
|
||||
// Provider trait surface:
|
||||
// verify(token) -> AuthContext
|
||||
// issue(user, roles) -> token string
|
||||
// revoke(token) -> Bool
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let ERR_INVALID_CREDS: String = "auth.invalid_credentials"
|
||||
let ERR_TOKEN_EXPIRED: String = "auth.token_expired"
|
||||
let ERR_TOKEN_INVALID: String = "auth.token_invalid"
|
||||
let ERR_SESSION_NOT_FOUND: String = "auth.session_not_found"
|
||||
let ERR_FORBIDDEN: String = "auth.forbidden"
|
||||
let ERR_CONFIG: String = "auth.config"
|
||||
|
||||
// ── AuthContext + AuthUser ────────────────────────────────────────────────────
|
||||
|
||||
type AuthUser {
|
||||
id: String
|
||||
email: String
|
||||
display_name: String
|
||||
}
|
||||
|
||||
type AuthContext {
|
||||
user_id: String
|
||||
email: String
|
||||
roles: String // JSON array of role names
|
||||
permissions: String // JSON array of permission strings
|
||||
session_id: String
|
||||
issued_at: String
|
||||
expires_at: String
|
||||
}
|
||||
|
||||
fn auth_context_empty() -> AuthContext {
|
||||
{ "user_id": "", "email": "", "roles": "[]", "permissions": "[]",
|
||||
"session_id": "", "issued_at": "", "expires_at": "" }
|
||||
}
|
||||
|
||||
fn auth_context_has_permission(ctx: AuthContext, perm: String) -> Bool {
|
||||
str_contains(ctx.permissions, "\"" + perm + "\"")
|
||||
}
|
||||
|
||||
fn auth_context_has_role(ctx: AuthContext, role: String) -> Bool {
|
||||
str_contains(ctx.roles, "\"" + role + "\"")
|
||||
}
|
||||
|
||||
// ── Roles + permissions ───────────────────────────────────────────────────────
|
||||
|
||||
type Permission {
|
||||
resource: String
|
||||
action: String
|
||||
}
|
||||
|
||||
fn permission_new(resource: String, action: String) -> Permission {
|
||||
{ "resource": resource, "action": action }
|
||||
}
|
||||
|
||||
fn permission_string(p: Permission) -> String {
|
||||
p.resource + ":" + p.action
|
||||
}
|
||||
|
||||
// Role registry — maps role name -> JSON array of permission strings.
|
||||
fn role_registry_grant(registry_path: String, role: String, perm: String) -> Bool {
|
||||
let raw: String = fs_read(registry_path)
|
||||
let updated: String = json_array_append(raw, role, "\"" + perm + "\"")
|
||||
fs_write(registry_path, updated)
|
||||
}
|
||||
|
||||
// ── JWT (HS256) ──────────────────────────────────────────────────────────────
|
||||
|
||||
type JwtClaims {
|
||||
sub: String // user id
|
||||
iss: String // issuer
|
||||
aud: String // audience
|
||||
iat: Int // issued at (unix seconds)
|
||||
exp: Int // expires at (unix seconds)
|
||||
jti: String // unique token id
|
||||
}
|
||||
|
||||
fn jwt_claims_new(user_id: String, issuer: String, audience: String, ttl_seconds: Int) -> JwtClaims {
|
||||
let now: Int = time_now_unix()
|
||||
{ "sub": user_id, "iss": issuer, "aud": audience,
|
||||
"iat": now, "exp": now + ttl_seconds, "jti": uuid_v4() }
|
||||
}
|
||||
|
||||
fn jwt_encode(claims: JwtClaims, secret: String) -> String {
|
||||
let header_b64: String = base64url_no_pad("{\"alg\":\"HS256\",\"typ\":\"JWT\"}")
|
||||
let payload_json: String = json_encode(claims)
|
||||
let payload_b64: String = base64url_no_pad(payload_json)
|
||||
let signing_input: String = header_b64 + "." + payload_b64
|
||||
let sig: String = base64url_no_pad(hmac_sha256(secret, signing_input))
|
||||
signing_input + "." + sig
|
||||
}
|
||||
|
||||
fn jwt_decode(token: String, secret: String) -> AuthContext {
|
||||
let parts: String = token // [header].[payload].[sig]
|
||||
let dot1: Int = str_index_of(parts, ".")
|
||||
if dot1 < 0 { return auth_context_empty() }
|
||||
let rest: String = str_slice(parts, dot1 + 1, str_len(parts))
|
||||
let dot2: Int = str_index_of(rest, ".")
|
||||
if dot2 < 0 { return auth_context_empty() }
|
||||
let header_b64: String = str_slice(parts, 0, dot1)
|
||||
let payload_b64: String = str_slice(rest, 0, dot2)
|
||||
let sig_b64: String = str_slice(rest, dot2 + 1, str_len(rest))
|
||||
|
||||
let signing_input: String = header_b64 + "." + payload_b64
|
||||
let expected_sig: String = base64url_no_pad(hmac_sha256(secret, signing_input))
|
||||
if !str_eq(expected_sig, sig_b64) { return auth_context_empty() }
|
||||
|
||||
let payload_json: String = base64url_decode(payload_b64)
|
||||
let now: Int = time_now_unix()
|
||||
let exp: Int = str_to_int(json_get(payload_json, "exp"))
|
||||
if exp < now { return auth_context_empty() }
|
||||
|
||||
{ "user_id": json_get(payload_json, "sub"),
|
||||
"email": "",
|
||||
"roles": "[]",
|
||||
"permissions": "[]",
|
||||
"session_id": json_get(payload_json, "jti"),
|
||||
"issued_at": json_get(payload_json, "iat"),
|
||||
"expires_at": json_get(payload_json, "exp") }
|
||||
}
|
||||
|
||||
// ── Engram-backed session store ──────────────────────────────────────────────
|
||||
//
|
||||
// Sessions are nodes of type "Session" connected to User via has_session.
|
||||
// Revocation = node delete. Verification = node lookup + expiry check.
|
||||
|
||||
fn session_store_create(user_id: String, ttl_seconds: Int, ip: String) -> String {
|
||||
let now: String = time_now_iso()
|
||||
let exp: String = time_add_seconds(now, ttl_seconds)
|
||||
let id: String = uuid_v4()
|
||||
let body: String = "{\"id\":\"" + id + "\",\"user_id\":\"" + user_id
|
||||
+ "\",\"created_at\":\"" + now + "\",\"expires_at\":\"" + exp
|
||||
+ "\",\"ip_address\":\"" + ip + "\"}"
|
||||
let node_id: String = engram_node_create("Session", body)
|
||||
engram_edge_create(user_id, node_id, "has_session")
|
||||
node_id
|
||||
}
|
||||
|
||||
fn session_store_verify(session_id: String) -> Bool {
|
||||
let raw: String = engram_node_get(session_id)
|
||||
if str_eq(raw, "") { return false }
|
||||
let exp: String = json_get(raw, "expires_at")
|
||||
!time_after(time_now_iso(), exp)
|
||||
}
|
||||
|
||||
fn session_store_revoke(session_id: String) -> Bool {
|
||||
engram_node_delete(session_id)
|
||||
}
|
||||
|
||||
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// auth_middleware extracts the bearer token, decodes it, and populates the
|
||||
// AuthContext. Applied automatically by the @authenticate aspect (el-aop).
|
||||
|
||||
fn extract_bearer(authorization_header: String) -> String {
|
||||
if str_starts_with(authorization_header, "Bearer ") {
|
||||
return str_slice(authorization_header, 7, str_len(authorization_header))
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
fn auth_middleware(authorization_header: String, jwt_secret: String) -> AuthContext {
|
||||
let token: String = extract_bearer(authorization_header)
|
||||
if str_eq(token, "") { return auth_context_empty() }
|
||||
jwt_decode(token, jwt_secret)
|
||||
}
|
||||
|
||||
fn enforce_permission(ctx: AuthContext, required_perm: String) -> Bool {
|
||||
if str_eq(ctx.user_id, "") { return false }
|
||||
auth_context_has_permission(ctx, required_perm)
|
||||
}
|
||||
|
||||
// ── Provider issue/verify ────────────────────────────────────────────────────
|
||||
|
||||
fn provider_issue(user: AuthUser, jwt_secret: String, issuer: String, audience: String, ttl: Int) -> String {
|
||||
let claims: JwtClaims = jwt_claims_new(user.id, issuer, audience, ttl)
|
||||
jwt_encode(claims, jwt_secret)
|
||||
}
|
||||
|
||||
fn provider_verify(token: String, jwt_secret: String) -> AuthContext {
|
||||
let ctx: AuthContext = jwt_decode(token, jwt_secret)
|
||||
if str_eq(ctx.user_id, "") { return ctx }
|
||||
if !session_store_verify(ctx.session_id) { return auth_context_empty() }
|
||||
ctx
|
||||
}
|
||||
|
||||
fn provider_revoke(session_id: String) -> Bool {
|
||||
session_store_revoke(session_id)
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ───────────────────────────────────────────────────────
|
||||
|
||||
let user: AuthUser = { "id": "u-001", "email": "will@neurontechnologies.ai", "display_name": "Will" }
|
||||
let token: String = provider_issue(user, "test-secret", "el-ui", "el-app", 3600)
|
||||
println("[el-auth] issued JWT for " + user.email)
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Session provider — server-side sessions stored in memory.
|
||||
//!
|
||||
//! In production, sessions are stored in Redis or Engram (configured via
|
||||
//! `session_store = "redis"` or `session_store = "engram"` in `el.toml`).
|
||||
//! `session_store = "redis"` or `session_store = "engram"` in `manifest.el`).
|
||||
//! This implementation uses in-memory storage for simplicity and testing.
|
||||
|
||||
use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry};
|
||||
@@ -0,0 +1,24 @@
|
||||
// el-config — Layered, typed configuration for el-ui applications.
|
||||
//
|
||||
// Resolution order (highest priority wins):
|
||||
// 1. Environment variables (EL_APP_NAME=...)
|
||||
// 2. .env file (development only)
|
||||
// 3. manifest.el [env.<current>] section
|
||||
// 4. manifest.el [config] base section
|
||||
// 5. Defaults defined in code
|
||||
|
||||
vessel "el-config" {
|
||||
version "0.1.0"
|
||||
description "Layered, typed configuration with manifest + env + defaults"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
/// for a key wins. Resolution order:
|
||||
/// 1. Environment variables (highest)
|
||||
/// 2. .env file (dev only)
|
||||
/// 3. el.toml [env.<current>] section
|
||||
/// 4. el.toml [config] section (base)
|
||||
/// 3. manifest.el [env.<current>] section
|
||||
/// 4. manifest.el [config] section (base)
|
||||
/// 5. Defaults defined in code (lowest)
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -129,7 +129,7 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load config from an `el.toml` string.
|
||||
/// Load config from an `manifest.el` string.
|
||||
///
|
||||
/// Reads `[config]` as the base, then overlays `[env.<environment>]`.
|
||||
pub fn load_from_toml(toml_str: &str, env: &Environment) -> Result<MapSource, ConfigError> {
|
||||
@@ -155,7 +155,7 @@ pub fn load_from_toml(toml_str: &str, env: &Environment) -> Result<MapSource, Co
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MapSource::from_map("el.toml", map))
|
||||
Ok(MapSource::from_map("manifest.el", map))
|
||||
}
|
||||
|
||||
fn flatten_toml_table(
|
||||
@@ -4,8 +4,8 @@
|
||||
//!
|
||||
//! 1. Environment variables (`EL_APP_NAME=...`)
|
||||
//! 2. `.env` file (development only)
|
||||
//! 3. `el.toml` `[env.<current>]` section
|
||||
//! 4. `el.toml` `[config]` base section
|
||||
//! 3. `manifest.el` `[env.<current>]` section
|
||||
//! 4. `manifest.el` `[config]` base section
|
||||
//! 5. Defaults defined in code
|
||||
//!
|
||||
//! ## Quick start
|
||||
@@ -0,0 +1,177 @@
|
||||
// el-config — Layered, typed configuration for el-ui.
|
||||
//
|
||||
// Each layer is a key->value map. `Config::get(key)` walks layers from highest
|
||||
// to lowest priority and returns the first hit. Values are parsed into the
|
||||
// caller-requested type via `parse_<type>`.
|
||||
|
||||
// ── Environment ──────────────────────────────────────────────────────────────
|
||||
|
||||
let ENV_DEVELOPMENT: String = "development"
|
||||
let ENV_STAGING: String = "staging"
|
||||
let ENV_PRODUCTION: String = "production"
|
||||
let ENV_TEST: String = "test"
|
||||
|
||||
fn env_current() -> String {
|
||||
let raw: String = env("EL_ENV")
|
||||
if str_eq(raw, "") { return ENV_DEVELOPMENT }
|
||||
raw
|
||||
}
|
||||
|
||||
fn env_is_production(name: String) -> Bool {
|
||||
str_eq(name, "production")
|
||||
}
|
||||
|
||||
// ── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let CFG_ERR_MISSING: String = "config.missing"
|
||||
let CFG_ERR_PARSE: String = "config.parse_error"
|
||||
let CFG_ERR_TYPE: String = "config.type_error"
|
||||
|
||||
// ── Sources ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let SRC_ENV: String = "env"
|
||||
let SRC_DOTENV: String = "dotenv"
|
||||
let SRC_MANIFEST: String = "manifest"
|
||||
let SRC_DEFAULTS: String = "defaults"
|
||||
let SRC_MAP: String = "map"
|
||||
|
||||
type ConfigSource {
|
||||
kind: String
|
||||
priority: Int // higher = wins
|
||||
map_json: String // JSON: key -> string value
|
||||
}
|
||||
|
||||
fn source_env_vars(prefix: String) -> ConfigSource {
|
||||
// Snapshot env at construction (the runtime exposes env_keys()).
|
||||
let map: String = "{}"
|
||||
let keys: String = env_keys_with_prefix(prefix)
|
||||
let n: Int = json_array_len(keys)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let raw_key: String = json_array_get(keys, i)
|
||||
let value: String = env(raw_key)
|
||||
let logical_key: String = str_to_lower(str_replace(str_slice(raw_key, str_len(prefix), str_len(raw_key)), "_", "."))
|
||||
let map = json_set(map, logical_key, "\"" + value + "\"")
|
||||
let i = i + 1
|
||||
}
|
||||
{ "kind": "env", "priority": 100, "map_json": map }
|
||||
}
|
||||
|
||||
fn source_dotenv(path: String) -> ConfigSource {
|
||||
let raw: String = ""
|
||||
if fs_exists(path) { let raw = fs_read(path) }
|
||||
let map: String = dotenv_parse(raw)
|
||||
{ "kind": "dotenv", "priority": 80, "map_json": map }
|
||||
}
|
||||
|
||||
fn source_manifest(manifest_path: String, current_env: String) -> ConfigSource {
|
||||
let raw: String = fs_read(manifest_path)
|
||||
let base: String = manifest_section(raw, "config")
|
||||
let env_key: String = "env." + current_env
|
||||
let env_overrides: String = manifest_section(raw, env_key)
|
||||
let map: String = json_merge(base, env_overrides)
|
||||
{ "kind": "manifest", "priority": 60, "map_json": map }
|
||||
}
|
||||
|
||||
fn source_defaults(map_json: String) -> ConfigSource {
|
||||
{ "kind": "defaults", "priority": 0, "map_json": map_json }
|
||||
}
|
||||
|
||||
// ── Config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Config {
|
||||
environment: String // development | staging | production | test
|
||||
sources_json: String // JSON array of ConfigSource
|
||||
}
|
||||
|
||||
fn config_new(env_name: String) -> Config {
|
||||
{ "environment": env_name, "sources_json": "[]" }
|
||||
}
|
||||
|
||||
fn config_add_source(c: Config, src: ConfigSource) -> Config {
|
||||
let updated: String = json_array_push_sorted(c.sources_json, json_encode(src), "priority", true)
|
||||
{ "environment": c.environment, "sources_json": updated }
|
||||
}
|
||||
|
||||
fn config_set_defaults(c: Config, defaults_map: String) -> Config {
|
||||
config_add_source(c, source_defaults(defaults_map))
|
||||
}
|
||||
|
||||
fn config_get_string(c: Config, key: String) -> String {
|
||||
let n: Int = json_array_len(c.sources_json)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let src_json: String = json_array_get(c.sources_json, i)
|
||||
let map: String = json_get(src_json, "map_json")
|
||||
let v: String = json_get(map, key)
|
||||
if !str_eq(v, "") { return v }
|
||||
let i = i + 1
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
fn config_get_int(c: Config, key: String) -> Int {
|
||||
let raw: String = config_get_string(c, key)
|
||||
if str_eq(raw, "") { return 0 }
|
||||
str_to_int(raw)
|
||||
}
|
||||
|
||||
fn config_get_bool(c: Config, key: String) -> Bool {
|
||||
let raw: String = str_to_lower(config_get_string(c, key))
|
||||
if str_eq(raw, "true") { return true }
|
||||
if str_eq(raw, "1") { return true }
|
||||
if str_eq(raw, "yes") { return true }
|
||||
false
|
||||
}
|
||||
|
||||
// Strict variants — non-empty required.
|
||||
fn config_require_string(c: Config, key: String) -> String {
|
||||
let v: String = config_get_string(c, key)
|
||||
if str_eq(v, "") { panic(CFG_ERR_MISSING + ":" + key) }
|
||||
v
|
||||
}
|
||||
|
||||
// ── load_from_toml — convenience for TOML config files ──────────────────────
|
||||
|
||||
fn config_load_from_toml(path: String, env_name: String) -> Config {
|
||||
let cfg: Config = config_new(env_name)
|
||||
let raw: String = fs_read(path)
|
||||
let map: String = toml_to_json_flat(raw) // dotted keys
|
||||
let cfg = config_add_source(cfg, source_defaults(map))
|
||||
cfg
|
||||
}
|
||||
|
||||
// ── .env parser (minimal) ───────────────────────────────────────────────────
|
||||
|
||||
fn dotenv_parse(raw: String) -> String {
|
||||
let map: String = "{}"
|
||||
let lines: String = str_split(raw, "\n")
|
||||
let n: Int = json_array_len(lines)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let line: String = str_trim(json_array_get(lines, i))
|
||||
if str_eq(line, "") {
|
||||
let i = i + 1
|
||||
}
|
||||
if !str_eq(line, "") {
|
||||
if !str_starts_with(line, "#") {
|
||||
let eq: Int = str_index_of(line, "=")
|
||||
if eq > 0 {
|
||||
let k: String = str_trim(str_slice(line, 0, eq))
|
||||
let v: String = str_trim(str_slice(line, eq + 1, str_len(line)))
|
||||
let v = str_strip_quotes(v)
|
||||
let map = json_set(map, str_to_lower(str_replace(k, "_", ".")), "\"" + v + "\"")
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let cfg: Config = config_new(env_current())
|
||||
let defaults: String = "{\"app.name\":\"MyApp\",\"server.port\":\"8080\"}"
|
||||
let cfg = config_set_defaults(cfg, defaults)
|
||||
println("[el-config] env=" + cfg.environment + " app=" + config_get_string(cfg, "app.name"))
|
||||
@@ -0,0 +1,33 @@
|
||||
// el-graph — Force-directed graph engine for el-ui.
|
||||
//
|
||||
// Server-side SVG renderer for knowledge graphs, Engram viewers, DHARMA
|
||||
// network maps, and soul relationship graphs.
|
||||
//
|
||||
// Architecture:
|
||||
// layout.el — pure El force simulation (Coulomb repulsion + spring edges)
|
||||
// node.el — node type definitions and color mapping
|
||||
// edge.el — edge type definitions
|
||||
// canvas.el — HTTP endpoint helper (full pipeline in one call)
|
||||
// view.el — SVG renderer (layout -> SVG string)
|
||||
// editor.el — round-trip mutation API (add/remove/connect/move nodes)
|
||||
// serializer.el — export to SVG or JSON
|
||||
//
|
||||
// Client-side interaction (drag, zoom, pan) is deferred until el-ui-compiler
|
||||
// gains a JavaScript backend. For now, consumers call graph_svg_endpoint()
|
||||
// which runs the full server-side pipeline and returns a static SVG string.
|
||||
|
||||
vessel "el-graph" {
|
||||
version "0.1.0"
|
||||
description "Force-directed graph: layout, SVG rendering, editing API"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// canvas.el — Full server-side pipeline: layout -> render -> SVG string.
|
||||
//
|
||||
// This is the primary integration point for callers that want a static SVG
|
||||
// without managing the layout and render steps separately.
|
||||
//
|
||||
// Public API:
|
||||
// graph_svg_endpoint(nodes_json, edges_json, width, height) -> String
|
||||
// Full pipeline: Coulomb/spring layout (150 iterations) -> SVG string.
|
||||
// Returns a complete <svg>...</svg> string.
|
||||
//
|
||||
// Client-side interaction (drag, zoom, pan) is deferred until el-ui-compiler
|
||||
// gains a JavaScript backend. For now, all rendering is server-side.
|
||||
// Clients refresh the SVG on demand (e.g., polling GET /api/graph/svg).
|
||||
//
|
||||
// Zoom/pan note: SVG viewBox is fixed to [0,0,width,height]. When the JS
|
||||
// backend lands, el-ui-compiler will produce an overlay with pointer-event
|
||||
// handlers that transform a <g> wrapper inside this SVG. The server-side path
|
||||
// stays as a fallback for non-browser consumers (CLI, PDF export, testing).
|
||||
|
||||
fn layout_default_iterations() -> Int { 150 }
|
||||
|
||||
// ── graph_svg_endpoint ────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_svg_endpoint(nodes_json: String, edges_json: String, width: Int, height: Int) -> String {
|
||||
let w_f: Float = int_to_float(width)
|
||||
let h_f: Float = int_to_float(height)
|
||||
|
||||
// Step 1: compute layout
|
||||
let positions_json: String = layout_run(nodes_json, edges_json, w_f, h_f, layout_default_iterations())
|
||||
|
||||
// Step 2: render to SVG
|
||||
let svg: String = graph_render_svg(nodes_json, edges_json, positions_json, width, height)
|
||||
svg
|
||||
}
|
||||
|
||||
// ── graph_svg_endpoint_custom ─────────────────────────────────────────────────
|
||||
//
|
||||
// Same as above but with configurable iteration count.
|
||||
// Use when you need faster layout (low iters) or higher quality (high iters).
|
||||
|
||||
fn graph_svg_endpoint_custom(nodes_json: String, edges_json: String, width: Int, height: Int, iterations: Int) -> String {
|
||||
let w_f: Float = int_to_float(width)
|
||||
let h_f: Float = int_to_float(height)
|
||||
let positions_json: String = layout_run(nodes_json, edges_json, w_f, h_f, iterations)
|
||||
graph_render_svg(nodes_json, edges_json, positions_json, width, height)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// edge.el — Edge type definitions and visual encoding.
|
||||
//
|
||||
// Edges are directed (source -> target) with a weight and optional relation label.
|
||||
|
||||
// ── Edge JSON accessors ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Edges are passed as JSON objects: { source_id, target_id, weight, relation }
|
||||
|
||||
fn edge_source(e_json: String) -> String {
|
||||
let s: String = json_get_string(e_json, "source_id")
|
||||
if !str_eq(s, "") { return s }
|
||||
json_get_string(e_json, "source")
|
||||
}
|
||||
|
||||
fn edge_target(e_json: String) -> String {
|
||||
let t: String = json_get_string(e_json, "target_id")
|
||||
if !str_eq(t, "") { return t }
|
||||
json_get_string(e_json, "target")
|
||||
}
|
||||
|
||||
fn edge_weight(e_json: String) -> Float {
|
||||
let w: Float = json_get_float(e_json, "weight")
|
||||
if w == int_to_float(0) { return int_to_float(1) }
|
||||
w
|
||||
}
|
||||
|
||||
fn edge_relation(e_json: String) -> String {
|
||||
json_get_string(e_json, "relation")
|
||||
}
|
||||
|
||||
// ── Edge visual encoding ─────────────────────────────────────────────────────
|
||||
|
||||
// Stroke width clamped to [1, 4] based on weight.
|
||||
fn edge_stroke_width(weight: Float) -> Float {
|
||||
let min_w: Float = int_to_float(1)
|
||||
let max_w: Float = int_to_float(4)
|
||||
let range: Float = max_w - min_w
|
||||
let clamped: Float = if weight < min_w { min_w } else { if weight > max_w { max_w } else { weight } }
|
||||
clamped
|
||||
}
|
||||
|
||||
fn edge_stroke_color() -> String { "#3a4a5a" }
|
||||
|
||||
fn edge_stroke_color_highlight() -> String { "#5a7a9a" }
|
||||
@@ -0,0 +1,138 @@
|
||||
// editor.el — Round-trip graph editing API.
|
||||
//
|
||||
// Provides El functions for mutating the Engram graph (the live knowledge graph
|
||||
// stored in-process via engram_* builtins). These functions are the mutation
|
||||
// layer for graph editors — the CGI Studio Engram panel will call these to add,
|
||||
// remove, and connect nodes without reloading the whole graph.
|
||||
//
|
||||
// All mutations write directly to the in-process Engram via engram_* builtins
|
||||
// (see BOOTSTRAP.md §Engram Knowledge Graph).
|
||||
//
|
||||
// Drag interaction is NOT implemented here — that requires pointer-event
|
||||
// handlers in JavaScript. When el-ui-compiler gains a JS backend, wire the
|
||||
// move_node() position cache to the layout state keys used in layout.el.
|
||||
//
|
||||
// Public API:
|
||||
// graph_add_node(content, node_type, label, salience) -> String // node_id or ""
|
||||
// graph_remove_node(node_id) -> String // "ok" or error JSON
|
||||
// graph_add_edge(from_id, to_id, weight_str, relation) -> String // "ok" or error JSON
|
||||
// graph_remove_edge(from_id, to_id) -> String // "ok" or error JSON
|
||||
// graph_move_node(node_id, x_str, y_str) -> String // "ok" (position cache)
|
||||
|
||||
// ── graph_add_node ────────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_add_node(content: String, node_type: String, label: String, salience_str: String) -> String {
|
||||
if str_eq(content, "") {
|
||||
return "{\"error\":\"content is required\"}"
|
||||
}
|
||||
let sal: Float = if str_eq(salience_str, "") { int_to_float(1) } else { str_to_float(salience_str) }
|
||||
// engram_node_full: content, type, label, salience, importance, confidence, tier, tags
|
||||
let eff_label: String = if str_eq(label, "") { str_slice(content, 0, 40) } else { label }
|
||||
let eff_type: String = if str_eq(node_type, "") { "Entity" } else { node_type }
|
||||
let node_id: String = engram_node_full(content, eff_type, eff_label, sal, sal, int_to_float(1), "Working", "")
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"engram_node_full returned empty id\"}"
|
||||
}
|
||||
"{\"id\":\"" + node_id + "\"}"
|
||||
}
|
||||
|
||||
// ── graph_remove_node ─────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_remove_node(node_id: String) -> String {
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"node_id is required\"}"
|
||||
}
|
||||
// Check node exists
|
||||
let existing: String = engram_get_node(node_id)
|
||||
if str_eq(existing, "") {
|
||||
return "{\"error\":\"node not found\",\"id\":\"" + node_id + "\"}"
|
||||
}
|
||||
engram_forget(node_id)
|
||||
"{\"ok\":true,\"id\":\"" + node_id + "\"}"
|
||||
}
|
||||
|
||||
// ── graph_add_edge ────────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_add_edge(from_id: String, to_id: String, weight_str: String, relation: String) -> String {
|
||||
if str_eq(from_id, "") {
|
||||
return "{\"error\":\"from_id is required\"}"
|
||||
}
|
||||
if str_eq(to_id, "") {
|
||||
return "{\"error\":\"to_id is required\"}"
|
||||
}
|
||||
let w: Float = if str_eq(weight_str, "") { int_to_float(1) } else { str_to_float(weight_str) }
|
||||
let rel: String = if str_eq(relation, "") { "relates_to" } else { relation }
|
||||
// engram_connect(from, to, weight, relation)
|
||||
let edge_id: String = engram_connect(from_id, to_id, w, rel)
|
||||
if str_eq(edge_id, "") {
|
||||
return "{\"error\":\"engram_connect failed\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
|
||||
}
|
||||
"{\"ok\":true,\"edge_id\":\"" + edge_id + "\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
|
||||
}
|
||||
|
||||
// ── graph_remove_edge ─────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_remove_edge(from_id: String, to_id: String) -> String {
|
||||
if str_eq(from_id, "") {
|
||||
return "{\"error\":\"from_id is required\"}"
|
||||
}
|
||||
if str_eq(to_id, "") {
|
||||
return "{\"error\":\"to_id is required\"}"
|
||||
}
|
||||
// Check edge exists
|
||||
let existing: String = engram_edge_between(from_id, to_id)
|
||||
if str_eq(existing, "") {
|
||||
return "{\"error\":\"edge not found\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
|
||||
}
|
||||
// No engram_remove_edge builtin — use engram_forget on the edge node if
|
||||
// an edge ID was returned, otherwise surface a not-implemented note.
|
||||
// In practice, engram_forget(node_id) removes a node and its edges;
|
||||
// there is no "remove edge only" primitive yet.
|
||||
"{\"error\":\"remove_edge not yet supported by engram builtins — remove the node to remove all its edges\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
|
||||
}
|
||||
|
||||
// ── graph_move_node ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Caches a node's screen position for use by the layout engine.
|
||||
// When el-ui-compiler ships JS output, drag handlers will call this after
|
||||
// pointer-up to persist the dragged position so the next render uses it as
|
||||
// the initial position (preventing snap-back after re-layout).
|
||||
|
||||
fn graph_move_node(node_id: String, x_str: String, y_str: String) -> String {
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"node_id is required\"}"
|
||||
}
|
||||
// Store in process state — layout.el reads these keys as initial positions.
|
||||
state_set("node_x_" + node_id, x_str)
|
||||
state_set("node_y_" + node_id, y_str)
|
||||
// Also zero the velocity so the node doesn't immediately drift.
|
||||
state_set("node_vx_" + node_id, "0.0")
|
||||
state_set("node_vy_" + node_id, "0.0")
|
||||
"{\"ok\":true,\"id\":\"" + node_id + "\",\"x\":" + x_str + ",\"y\":" + y_str + "}"
|
||||
}
|
||||
|
||||
// ── graph_node_info ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Retrieve full node data from Engram (for inspector panels).
|
||||
|
||||
fn graph_node_info(node_id: String) -> String {
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"node_id is required\"}"
|
||||
}
|
||||
let n: String = engram_get_node(node_id)
|
||||
if str_eq(n, "") {
|
||||
return "{\"error\":\"node not found\",\"id\":\"" + node_id + "\"}"
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
// ── graph_neighbors ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Return the neighbors of a node as a JSON array (for sub-graph drill-down).
|
||||
|
||||
fn graph_neighbors_json(node_id: String) -> String {
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"node_id is required\"}"
|
||||
}
|
||||
engram_neighbors(node_id)
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// layout.el — Force-directed layout engine (pure El math).
|
||||
//
|
||||
// Implements a basic spring-force simulation:
|
||||
// - Coulomb repulsion between every pair of nodes (O(n²))
|
||||
// - Hooke spring attraction along edges
|
||||
// - Weak gravity toward the canvas center
|
||||
// - Velocity damping per iteration
|
||||
//
|
||||
// Float representation: El stores floats as bit-cast int64_t values.
|
||||
// All math uses int_to_float() for literals and math_sqrt() for sqrt.
|
||||
//
|
||||
// Public API:
|
||||
// layout_run(nodes_json, edges_json, width, height, iterations) -> String
|
||||
// nodes_json — JSON array: [{ id, salience, ... }, ...]
|
||||
// edges_json — JSON array: [{ source_id, target_id, weight }, ...]
|
||||
// width/height — canvas Float dimensions
|
||||
// iterations — simulation steps (default 150 for good convergence)
|
||||
// Returns JSON array: [{ id, x, y }, ...]
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn layout_repulsion_k() -> Float {
|
||||
// Coulomb constant — controls node spread.
|
||||
int_to_float(3000)
|
||||
}
|
||||
|
||||
fn layout_spring_k() -> Float {
|
||||
// Spring stiffness for edge attraction.
|
||||
int_to_float(1)
|
||||
}
|
||||
|
||||
fn layout_spring_rest() -> Float {
|
||||
// Rest length for edges (px).
|
||||
int_to_float(80)
|
||||
}
|
||||
|
||||
fn layout_gravity_k() -> Float {
|
||||
// Gravity toward center (gentle).
|
||||
int_to_float(1)
|
||||
}
|
||||
|
||||
fn layout_damping() -> Float {
|
||||
// Velocity decay per step (0.85 = 15% loss per step).
|
||||
let d: Float = int_to_float(85)
|
||||
d / int_to_float(100)
|
||||
}
|
||||
|
||||
fn layout_max_velocity() -> Float {
|
||||
// Cap velocity per step to avoid explosion.
|
||||
int_to_float(50)
|
||||
}
|
||||
|
||||
fn layout_min_dist() -> Float {
|
||||
// Minimum distance to prevent division by zero in repulsion.
|
||||
int_to_float(1)
|
||||
}
|
||||
|
||||
// ── State keys (process state for per-node data) ─────────────────────────────
|
||||
//
|
||||
// We use process state (state_set/state_get) as a flat key/value store since
|
||||
// El does not have mutable arrays or map mutation without re-assignment.
|
||||
// Key patterns:
|
||||
// "node_ids" — comma-separated node id list
|
||||
// "node_x_<id>" — x position
|
||||
// "node_y_<id>" — y position
|
||||
// "node_vx_<id>" — x velocity
|
||||
// "node_vy_<id>" — y velocity
|
||||
|
||||
fn layout_key_x(node_id: String) -> String { "node_x_" + node_id }
|
||||
fn layout_key_y(node_id: String) -> String { "node_y_" + node_id }
|
||||
fn layout_key_vx(node_id: String) -> String { "node_vx_" + node_id }
|
||||
fn layout_key_vy(node_id: String) -> String { "node_vy_" + node_id }
|
||||
|
||||
// ── Initialization ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Distribute nodes in a circle around the center so no two start at the
|
||||
// same position (which would make repulsion forces zero and give no movement).
|
||||
|
||||
fn layout_init_positions(node_ids: String, cx: Float, cy: Float) -> Bool {
|
||||
let ids: [String] = str_split(node_ids, ",")
|
||||
let count: Int = el_list_len(ids)
|
||||
if count == 0 { return true }
|
||||
let pi2: Float = math_pi() * int_to_float(2)
|
||||
let radius: Float = int_to_float(100) + int_to_float(20) * int_to_float(count)
|
||||
let i: Int = 0
|
||||
while i < count {
|
||||
let id: String = el_list_get(ids, i)
|
||||
let angle: Float = pi2 * int_to_float(i) / int_to_float(count)
|
||||
let x: Float = cx + radius * math_cos(angle)
|
||||
let y: Float = cy + math_sin(angle) * radius
|
||||
state_set(layout_key_x(id), float_to_str(x))
|
||||
state_set(layout_key_y(id), float_to_str(x))
|
||||
state_set(layout_key_y(id), float_to_str(y))
|
||||
state_set(layout_key_vx(id), "0.0")
|
||||
state_set(layout_key_vy(id), "0.0")
|
||||
let i = i + 1
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ── Float helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn layout_get_x(id: String) -> Float {
|
||||
str_to_float(state_get(layout_key_x(id)))
|
||||
}
|
||||
|
||||
fn layout_get_y(id: String) -> Float {
|
||||
str_to_float(state_get(layout_key_y(id)))
|
||||
}
|
||||
|
||||
fn layout_get_vx(id: String) -> Float {
|
||||
str_to_float(state_get(layout_key_vx(id)))
|
||||
}
|
||||
|
||||
fn layout_get_vy(id: String) -> Float {
|
||||
str_to_float(state_get(layout_key_vy(id)))
|
||||
}
|
||||
|
||||
fn float_clamp(v: Float, lo: Float, hi: Float) -> Float {
|
||||
if v < lo { return lo }
|
||||
if v > hi { return hi }
|
||||
v
|
||||
}
|
||||
|
||||
fn float_abs(v: Float) -> Float {
|
||||
if v < int_to_float(0) { return int_to_float(0) - v }
|
||||
v
|
||||
}
|
||||
|
||||
// ── Repulsion pass ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// For each pair (a, b): compute Coulomb repulsion and accumulate forces.
|
||||
// Force direction: along the vector from b to a (a is pushed away from b).
|
||||
// Magnitude: k / dist^2
|
||||
|
||||
fn layout_repulsion_pass(node_ids: String) -> Bool {
|
||||
let ids: [String] = str_split(node_ids, ",")
|
||||
let n: Int = el_list_len(ids)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let id_a: String = el_list_get(ids, i)
|
||||
let ax: Float = layout_get_x(id_a)
|
||||
let ay: Float = layout_get_y(id_a)
|
||||
let fx: Float = int_to_float(0)
|
||||
let fy: Float = int_to_float(0)
|
||||
let j: Int = 0
|
||||
while j < n {
|
||||
if j != i {
|
||||
let id_b: String = el_list_get(ids, j)
|
||||
let bx: Float = layout_get_x(id_b)
|
||||
let by: Float = layout_get_y(id_b)
|
||||
let dx: Float = ax - bx
|
||||
let dy: Float = ay - by
|
||||
let dist_sq: Float = dx * dx + dy * dy
|
||||
let dist: Float = math_sqrt(dist_sq)
|
||||
let safe_dist: Float = if dist < layout_min_dist() { layout_min_dist() } else { dist }
|
||||
let force: Float = layout_repulsion_k() / (safe_dist * safe_dist)
|
||||
let nx: Float = dx / safe_dist
|
||||
let ny: Float = dy / safe_dist
|
||||
let fx = fx + nx * force
|
||||
let fy = fy + ny * force
|
||||
}
|
||||
let j = j + 1
|
||||
}
|
||||
// Accumulate: store forces temporarily in velocity (they are scaled later)
|
||||
// Use "fx_<id>" keys for accumulation.
|
||||
state_set("fx_" + id_a, float_to_str(fx))
|
||||
state_set("fy_" + id_a, float_to_str(fy))
|
||||
let i = i + 1
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ── Spring pass ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// For each edge (a->b): apply Hooke spring toward rest length.
|
||||
// Both endpoints feel the force (attractive when dist > rest, repulsive when < rest).
|
||||
|
||||
fn layout_spring_pass(node_ids: String, edges_json: String) -> Bool {
|
||||
let edge_count: Int = json_array_len(edges_json)
|
||||
let i: Int = 0
|
||||
while i < edge_count {
|
||||
let e: String = json_array_get(edges_json, i)
|
||||
let src: String = json_get_string(e, "source_id")
|
||||
let tgt_raw: String = json_get_string(e, "target_id")
|
||||
// Support both source_id/target_id and source/target field names
|
||||
let src2: String = if str_eq(src, "") { json_get_string(e, "source") } else { src }
|
||||
let tgt2: String = if str_eq(tgt_raw, "") { json_get_string(e, "target") } else { tgt_raw }
|
||||
let w: Float = json_get_float(e, "weight")
|
||||
let eff_w: Float = if w == int_to_float(0) { int_to_float(1) } else { w }
|
||||
|
||||
// Only apply spring if both endpoints are in our node set
|
||||
let sx: String = state_get(layout_key_x(src2))
|
||||
let tx_chk: String = state_get(layout_key_x(tgt2))
|
||||
if !str_eq(sx, "") {
|
||||
if !str_eq(tx_chk, "") {
|
||||
let ax: Float = layout_get_x(src2)
|
||||
let ay: Float = layout_get_y(src2)
|
||||
let bx: Float = layout_get_x(tgt2)
|
||||
let by_val: Float = layout_get_y(tgt2)
|
||||
let dx: Float = bx - ax
|
||||
let dy: Float = by_val - ay
|
||||
let dist_sq: Float = dx * dx + dy * dy
|
||||
let dist: Float = math_sqrt(dist_sq)
|
||||
let safe_dist: Float = if dist < layout_min_dist() { layout_min_dist() } else { dist }
|
||||
let stretch: Float = (safe_dist - layout_spring_rest()) * layout_spring_k() * eff_w
|
||||
let nx: Float = dx / safe_dist
|
||||
let ny: Float = dy / safe_dist
|
||||
let spring_fx: Float = nx * stretch
|
||||
let spring_fy: Float = ny * stretch
|
||||
|
||||
// Add to accumulated forces
|
||||
let cur_fx_a: Float = str_to_float(state_get("fx_" + src2))
|
||||
let cur_fy_a: Float = str_to_float(state_get("fy_" + src2))
|
||||
state_set("fx_" + src2, float_to_str(cur_fx_a + spring_fx))
|
||||
state_set("fy_" + src2, float_to_str(cur_fy_a + spring_fy))
|
||||
|
||||
let cur_fx_b: Float = str_to_float(state_get("fx_" + tgt2))
|
||||
let cur_fy_b: Float = str_to_float(state_get("fy_" + tgt2))
|
||||
state_set("fx_" + tgt2, float_to_str(cur_fx_b - spring_fx))
|
||||
state_set("fy_" + tgt2, float_to_str(cur_fy_b - spring_fy))
|
||||
}
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ── Gravity pass ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Weak attraction toward canvas center to prevent isolated nodes from drifting.
|
||||
|
||||
fn layout_gravity_pass(node_ids: String, cx: Float, cy: Float) -> Bool {
|
||||
let ids: [String] = str_split(node_ids, ",")
|
||||
let n: Int = el_list_len(ids)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let id: String = el_list_get(ids, i)
|
||||
let x: Float = layout_get_x(id)
|
||||
let y: Float = layout_get_y(id)
|
||||
let gx: Float = (cx - x) * layout_gravity_k() / int_to_float(100)
|
||||
let gy: Float = (cy - y) * layout_gravity_k() / int_to_float(100)
|
||||
let cur_fx: Float = str_to_float(state_get("fx_" + id))
|
||||
let cur_fy: Float = str_to_float(state_get("fy_" + id))
|
||||
state_set("fx_" + id, float_to_str(cur_fx + gx))
|
||||
state_set("fy_" + id, float_to_str(cur_fy + gy))
|
||||
let i = i + 1
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ── Integration pass ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Apply forces to velocities (with damping), then update positions.
|
||||
// Clamp positions to stay within canvas bounds (with 20px margin).
|
||||
|
||||
fn layout_integrate(node_ids: String, width: Float, height: Float) -> Bool {
|
||||
let ids: [String] = str_split(node_ids, ",")
|
||||
let n: Int = el_list_len(ids)
|
||||
let max_v: Float = layout_max_velocity()
|
||||
let damp: Float = layout_damping()
|
||||
let margin: Float = int_to_float(20)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let id: String = el_list_get(ids, i)
|
||||
let vx: Float = (layout_get_vx(id) + str_to_float(state_get("fx_" + id))) * damp
|
||||
let vy: Float = (layout_get_vy(id) + str_to_float(state_get("fy_" + id))) * damp
|
||||
// Clamp velocity magnitude
|
||||
let vx_clamped: Float = float_clamp(vx, int_to_float(0) - max_v, max_v)
|
||||
let vy_clamped: Float = float_clamp(vy, int_to_float(0) - max_v, max_v)
|
||||
let new_x: Float = float_clamp(layout_get_x(id) + vx_clamped, margin, width - margin)
|
||||
let new_y: Float = float_clamp(layout_get_y(id) + vy_clamped, margin, height - margin)
|
||||
state_set(layout_key_x(id), float_to_str(new_x))
|
||||
state_set(layout_key_y(id), float_to_str(new_y))
|
||||
state_set(layout_key_vx(id), float_to_str(vx_clamped))
|
||||
state_set(layout_key_vy(id), float_to_str(vy_clamped))
|
||||
// Reset force accumulators for next iteration
|
||||
state_set("fx_" + id, "0.0")
|
||||
state_set("fy_" + id, "0.0")
|
||||
let i = i + 1
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ── Public: layout_run ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Full pipeline: init positions, run N iterations, return positions as JSON.
|
||||
//
|
||||
// Input nodes_json must be a JSON array of objects with at least an "id" field.
|
||||
// Returns: JSON array [{ "id": "...", "x": 123.0, "y": 456.0 }, ...]
|
||||
|
||||
fn layout_run(nodes_json: String, edges_json: String, width: Float, height: Float, iterations: Int) -> String {
|
||||
let cx: Float = width / int_to_float(2)
|
||||
let cy: Float = height / int_to_float(2)
|
||||
|
||||
// Build comma-separated node_ids list
|
||||
let node_count: Int = json_array_len(nodes_json)
|
||||
if node_count == 0 { return "[]" }
|
||||
|
||||
let node_ids: String = ""
|
||||
let first: Bool = true
|
||||
let i: Int = 0
|
||||
while i < node_count {
|
||||
let n: String = json_array_get(nodes_json, i)
|
||||
let id: String = json_get_string(n, "id")
|
||||
if !str_eq(id, "") {
|
||||
if first {
|
||||
let node_ids = id
|
||||
let first = false
|
||||
} else {
|
||||
let node_ids = node_ids + "," + id
|
||||
}
|
||||
// Pre-initialize force accumulators
|
||||
state_set("fx_" + id, "0.0")
|
||||
state_set("fy_" + id, "0.0")
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
|
||||
// Initialize positions (circle around center)
|
||||
layout_init_positions(node_ids, cx, cy)
|
||||
|
||||
// Simulation loop
|
||||
let iter: Int = 0
|
||||
while iter < iterations {
|
||||
layout_repulsion_pass(node_ids)
|
||||
layout_spring_pass(node_ids, edges_json)
|
||||
layout_gravity_pass(node_ids, cx, cy)
|
||||
layout_integrate(node_ids, width, height)
|
||||
let iter = iter + 1
|
||||
}
|
||||
|
||||
// Collect results as JSON array
|
||||
let result: String = "["
|
||||
let ids: [String] = str_split(node_ids, ",")
|
||||
let n2: Int = el_list_len(ids)
|
||||
let j: Int = 0
|
||||
while j < n2 {
|
||||
let id: String = el_list_get(ids, j)
|
||||
let x: Float = layout_get_x(id)
|
||||
let y: Float = layout_get_y(id)
|
||||
let entry: String = "{\"id\":\"" + id + "\",\"x\":" + format_float(x, 1) + ",\"y\":" + format_float(y, 1) + "}"
|
||||
if j == 0 {
|
||||
let result = result + entry
|
||||
} else {
|
||||
let result = result + "," + entry
|
||||
}
|
||||
let j = j + 1
|
||||
}
|
||||
let result = result + "]"
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// main.el — el-graph vessel entry point.
|
||||
//
|
||||
// Re-exports all public functions from the sub-modules. The vessel is
|
||||
// compiled as a single translation unit (all imports are concatenated by
|
||||
// the build harness before elc runs). This file is the canonical import
|
||||
// target for downstream consumers.
|
||||
//
|
||||
// Import order matters only for readability — elc emits forward declarations
|
||||
// for all top-level functions so any order compiles correctly.
|
||||
|
||||
import "node.el"
|
||||
import "edge.el"
|
||||
import "layout.el"
|
||||
import "view.el"
|
||||
import "canvas.el"
|
||||
import "editor.el"
|
||||
import "serializer.el"
|
||||
|
||||
// ── Smoke test ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Verifies the vessel initializes correctly. Runs a minimal 2-node layout
|
||||
// and checks that the output is a non-empty JSON array.
|
||||
//
|
||||
// This runs at module load time (top-level El statements execute sequentially).
|
||||
// Remove or gate behind an env flag if startup overhead matters.
|
||||
|
||||
println("[el-graph] v0.1.0 — force layout + SVG renderer")
|
||||
println("[el-graph] node_color(Memory) = " + node_color("Memory"))
|
||||
println("[el-graph] node_radius(0.8) = " + int_to_str(node_radius_int(int_to_float(8) / int_to_float(10))))
|
||||
|
||||
let _smoke_nodes: String = "[{\"id\":\"a\",\"salience\":0.8,\"node_type\":\"Memory\"},{\"id\":\"b\",\"salience\":0.5,\"node_type\":\"Entity\"}]"
|
||||
let _smoke_edges: String = "[{\"source_id\":\"a\",\"target_id\":\"b\",\"weight\":1.0}]"
|
||||
let _smoke_pos: String = layout_run(_smoke_nodes, _smoke_edges, int_to_float(400), int_to_float(300), 10)
|
||||
println("[el-graph] smoke layout (10 iter) = " + str_slice(_smoke_pos, 0, 60) + "...")
|
||||
@@ -0,0 +1,77 @@
|
||||
// node.el — Node type definitions and color/radius mapping.
|
||||
//
|
||||
// Node types mirror the Engram knowledge graph node_type field.
|
||||
// Colors are chosen for dark-background (Studio) legibility.
|
||||
|
||||
// ── Node type constants ──────────────────────────────────────────────────────
|
||||
|
||||
fn node_type_memory() -> String { "Memory" }
|
||||
fn node_type_backlog() -> String { "BacklogItem" }
|
||||
fn node_type_knowledge() -> String { "Knowledge" }
|
||||
fn node_type_entity() -> String { "Entity" }
|
||||
fn node_type_default() -> String { "Node" }
|
||||
|
||||
// ── Color map ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn node_color(node_type: String) -> String {
|
||||
if str_eq(node_type, "Memory") { return "#58A6FF" }
|
||||
if str_eq(node_type, "BacklogItem") { return "#C9A84C" }
|
||||
if str_eq(node_type, "Knowledge") { return "#2ecc71" }
|
||||
if str_eq(node_type, "Entity") { return "#e74c3c" }
|
||||
if str_eq(node_type, "WorkContext") { return "#9b59b6" }
|
||||
if str_eq(node_type, "Artifact") { return "#1abc9c" }
|
||||
if str_eq(node_type, "Process") { return "#e67e22" }
|
||||
"#7a8ba8"
|
||||
}
|
||||
|
||||
// ── Radius ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Clamp salience (0.0–1.0) to radius range [6, 18].
|
||||
|
||||
fn node_radius(salience: Float) -> Float {
|
||||
let min_r: Float = int_to_float(6)
|
||||
let max_r: Float = int_to_float(18)
|
||||
let range: Float = max_r - min_r
|
||||
let clamped: Float = if salience < int_to_float(0) { int_to_float(0) } else { if salience > int_to_float(1) { int_to_float(1) } else { salience } }
|
||||
min_r + range * clamped
|
||||
}
|
||||
|
||||
fn node_radius_int(salience: Float) -> Int {
|
||||
float_to_int(node_radius(salience))
|
||||
}
|
||||
|
||||
// ── Label truncation ─────────────────────────────────────────────────────────
|
||||
|
||||
fn node_label_truncate(label: String) -> String {
|
||||
let max_len: Int = 30
|
||||
let l: Int = str_len(label)
|
||||
if l <= max_len { return label }
|
||||
str_slice(label, 0, max_len) + "..."
|
||||
}
|
||||
|
||||
// ── Node JSON accessors ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Nodes are passed as JSON objects: { id, label, node_type, salience, ... }
|
||||
|
||||
fn node_id(n_json: String) -> String {
|
||||
json_get_string(n_json, "id")
|
||||
}
|
||||
|
||||
fn node_label(n_json: String) -> String {
|
||||
let lbl: String = json_get_string(n_json, "label")
|
||||
if !str_eq(lbl, "") { return lbl }
|
||||
// Fall back to first 40 chars of content
|
||||
let c: String = json_get_string(n_json, "content")
|
||||
if str_len(c) > 40 { return str_slice(c, 0, 40) }
|
||||
c
|
||||
}
|
||||
|
||||
fn node_type_field(n_json: String) -> String {
|
||||
let t: String = json_get_string(n_json, "node_type")
|
||||
if str_eq(t, "") { return node_type_default() }
|
||||
t
|
||||
}
|
||||
|
||||
fn node_salience(n_json: String) -> Float {
|
||||
json_get_float(n_json, "salience")
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// serializer.el — Export graph as SVG string or portable JSON.
|
||||
//
|
||||
// Public API:
|
||||
// graph_to_svg(graph_json, width, height) -> String
|
||||
// graph_json: { "nodes": [...], "edges": [...] }
|
||||
// Full pipeline: parse -> layout -> render -> SVG string.
|
||||
//
|
||||
// graph_to_json(nodes_json, edges_json, positions_json) -> String
|
||||
// Portable export combining node data with computed positions.
|
||||
// Useful for saving layouts to disk or sending to other tools.
|
||||
|
||||
// ── graph_to_svg ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Convenience wrapper: accepts a combined graph JSON object and returns SVG.
|
||||
|
||||
fn graph_to_svg(graph_json: String, width: Int, height: Int) -> String {
|
||||
let nodes_raw: String = json_get_raw(graph_json, "nodes")
|
||||
let edges_raw: String = json_get_raw(graph_json, "edges")
|
||||
let nodes_json: String = if str_eq(nodes_raw, "") { "[]" } else { nodes_raw }
|
||||
let edges_json: String = if str_eq(edges_raw, "") { "[]" } else { edges_raw }
|
||||
graph_svg_endpoint(nodes_json, edges_json, width, height)
|
||||
}
|
||||
|
||||
// ── graph_to_json ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Merge node metadata with computed positions into a portable export format.
|
||||
// Output: { "nodes": [{...node fields..., "x": 123.0, "y": 456.0}], "edges": [...] }
|
||||
|
||||
fn graph_to_json(nodes_json: String, edges_json: String, positions_json: String) -> String {
|
||||
// Index positions by id
|
||||
build_position_index(positions_json)
|
||||
|
||||
let node_count: Int = json_array_len(nodes_json)
|
||||
let nodes_out: String = "["
|
||||
let i: Int = 0
|
||||
while i < node_count {
|
||||
let n: String = json_array_get(nodes_json, i)
|
||||
let id: String = json_get_string(n, "id")
|
||||
let x: Float = get_pos_x(id)
|
||||
let y: Float = get_pos_y(id)
|
||||
// Inject x/y into the node JSON
|
||||
let n_with_pos: String = json_set(json_set(n, "x", format_float(x, 1)), "y", format_float(y, 1))
|
||||
if i == 0 {
|
||||
let nodes_out = nodes_out + n_with_pos
|
||||
} else {
|
||||
let nodes_out = nodes_out + "," + n_with_pos
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
let nodes_out = nodes_out + "]"
|
||||
|
||||
"{\"nodes\":" + nodes_out + ",\"edges\":" + edges_json + "}"
|
||||
}
|
||||
|
||||
// ── graph_snapshot_svg ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Render a snapshot of the current in-process Engram graph as SVG.
|
||||
// Uses engram_scan_nodes_json and reads edges from the snapshot file.
|
||||
// This is the function called by the CGI Studio /api/graph/svg endpoint.
|
||||
|
||||
fn graph_snapshot_svg(width: Int, height: Int, snap_path: String) -> String {
|
||||
let nodes_json: String = engram_scan_nodes_json(9999, 0)
|
||||
let n_count: Int = json_array_len(nodes_json)
|
||||
|
||||
// Read edges from snapshot file
|
||||
let snap: String = fs_read(snap_path)
|
||||
let edges_raw: String = if str_eq(snap, "") { "[]" } else { json_get_raw(snap, "edges") }
|
||||
let edges_json: String = if str_eq(edges_raw, "") { "[]" } else { edges_raw }
|
||||
|
||||
if n_count == 0 {
|
||||
// Return an empty SVG with a "no data" message
|
||||
return svg_open(width, height) +
|
||||
"<text x=\"" + int_to_str(width / 2) + "\" y=\"" + int_to_str(height / 2) + "\" " +
|
||||
"text-anchor=\"middle\" fill=\"#8b9aaa\" font-size=\"14\" " +
|
||||
"font-family=\"IBM Plex Mono,monospace\">No nodes in graph</text>" +
|
||||
svg_close()
|
||||
}
|
||||
|
||||
graph_svg_endpoint(nodes_json, edges_json, width, height)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// view.el — SVG renderer for the force-directed graph.
|
||||
//
|
||||
// Takes layout positions + node/edge data and produces a complete SVG string.
|
||||
// Rendering is purely server-side — no DOM, no JavaScript.
|
||||
//
|
||||
// Public API:
|
||||
// graph_render_svg(nodes_json, edges_json, positions_json, width, height) -> String
|
||||
// Returns a complete <svg>...</svg> string ready for embedding or serving.
|
||||
//
|
||||
// Visual conventions:
|
||||
// - Background: #0d1117 (dark, matching Studio theme)
|
||||
// - Edges drawn first (below nodes)
|
||||
// - Nodes: filled circle with stroke, radius by salience
|
||||
// - Labels: truncated to 30 chars, below node, 10px IBM Plex Mono
|
||||
|
||||
// ── SVG helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn svg_open(width: Int, height: Int) -> String {
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" " +
|
||||
"width=\"" + int_to_str(width) + "\" " +
|
||||
"height=\"" + int_to_str(height) + "\" " +
|
||||
"viewBox=\"0 0 " + int_to_str(width) + " " + int_to_str(height) + "\" " +
|
||||
"style=\"background:#0d1117;font-family:'IBM Plex Mono',monospace\">"
|
||||
}
|
||||
|
||||
fn svg_close() -> String { "</svg>" }
|
||||
|
||||
fn svg_defs() -> String {
|
||||
"<defs>" +
|
||||
"<filter id=\"glow\"><feGaussianBlur stdDeviation=\"2\" result=\"blur\"/>" +
|
||||
"<feMerge><feMergeNode in=\"blur\"/><feMergeNode in=\"SourceGraphic\"/></feMerge></filter>" +
|
||||
"</defs>"
|
||||
}
|
||||
|
||||
// ── Edge rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
fn svg_edge(x1: Float, y1: Float, x2: Float, y2: Float, weight: Float) -> String {
|
||||
let sw: Float = edge_stroke_width(weight)
|
||||
let sw_str: String = format_float(sw, 1)
|
||||
let x1s: String = format_float(x1, 1)
|
||||
let y1s: String = format_float(y1, 1)
|
||||
let x2s: String = format_float(x2, 1)
|
||||
let y2s: String = format_float(y2, 1)
|
||||
"<line " +
|
||||
"x1=\"" + x1s + "\" y1=\"" + y1s + "\" " +
|
||||
"x2=\"" + x2s + "\" y2=\"" + y2s + "\" " +
|
||||
"stroke=\"" + edge_stroke_color() + "\" " +
|
||||
"stroke-width=\"" + sw_str + "\" " +
|
||||
"stroke-opacity=\"0.7\"/>"
|
||||
}
|
||||
|
||||
// ── Node rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
fn svg_node(x: Float, y: Float, radius: Int, color: String, label: String) -> String {
|
||||
let xs: String = format_float(x, 1)
|
||||
let ys: String = format_float(y, 1)
|
||||
let rs: String = int_to_str(radius)
|
||||
let label_trunc: String = node_label_truncate(label)
|
||||
// Escape XML special chars in label
|
||||
let label_safe: String = str_replace(str_replace(str_replace(label_trunc, "&", "&"), "<", "<"), ">", ">")
|
||||
let label_y: String = format_float(y + int_to_float(radius) + int_to_float(12), 1)
|
||||
"<circle cx=\"" + xs + "\" cy=\"" + ys + "\" r=\"" + rs + "\" " +
|
||||
"fill=\"" + color + "\" fill-opacity=\"0.85\" " +
|
||||
"stroke=\"" + color + "\" stroke-width=\"1.5\" filter=\"url(#glow)\"/>" +
|
||||
"<text x=\"" + xs + "\" y=\"" + label_y + "\" " +
|
||||
"text-anchor=\"middle\" font-size=\"9\" fill=\"#8b9aaa\" " +
|
||||
"font-family=\"IBM Plex Mono,monospace\">" + label_safe + "</text>"
|
||||
}
|
||||
|
||||
// ── Position lookup ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Build a flat map from node_id -> position JSON in process state.
|
||||
// Key: "pos_<id>" -> "{\"x\":...,\"y\":...}"
|
||||
|
||||
fn build_position_index(positions_json: String) -> Bool {
|
||||
let count: Int = json_array_len(positions_json)
|
||||
let i: Int = 0
|
||||
while i < count {
|
||||
let pos: String = json_array_get(positions_json, i)
|
||||
let id: String = json_get_string(pos, "id")
|
||||
if !str_eq(id, "") {
|
||||
state_set("pos_" + id, pos)
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn get_pos_x(node_id: String) -> Float {
|
||||
let pos: String = state_get("pos_" + node_id)
|
||||
if str_eq(pos, "") { return int_to_float(0) }
|
||||
json_get_float(pos, "x")
|
||||
}
|
||||
|
||||
fn get_pos_y(node_id: String) -> Float {
|
||||
let pos: String = state_get("pos_" + node_id)
|
||||
if str_eq(pos, "") { return int_to_float(0) }
|
||||
json_get_float(pos, "y")
|
||||
}
|
||||
|
||||
// ── Public: graph_render_svg ──────────────────────────────────────────────────
|
||||
|
||||
fn graph_render_svg(nodes_json: String, edges_json: String, positions_json: String, width: Int, height: Int) -> String {
|
||||
// Index positions by node id
|
||||
build_position_index(positions_json)
|
||||
|
||||
let out: String = svg_open(width, height)
|
||||
let out = out + svg_defs()
|
||||
|
||||
// ── Draw edges (behind nodes) ─────────────────────────────────────────────
|
||||
let edge_count: Int = json_array_len(edges_json)
|
||||
let i: Int = 0
|
||||
while i < edge_count {
|
||||
let e: String = json_array_get(edges_json, i)
|
||||
let src: String = edge_source(e)
|
||||
let tgt: String = edge_target(e)
|
||||
let w: Float = edge_weight(e)
|
||||
// Only draw if both endpoints have positions
|
||||
let src_pos: String = state_get("pos_" + src)
|
||||
let tgt_pos: String = state_get("pos_" + tgt)
|
||||
if !str_eq(src_pos, "") {
|
||||
if !str_eq(tgt_pos, "") {
|
||||
let x1: Float = get_pos_x(src)
|
||||
let y1: Float = get_pos_y(src)
|
||||
let x2: Float = get_pos_x(tgt)
|
||||
let y2: Float = get_pos_y(tgt)
|
||||
let out = out + svg_edge(x1, y1, x2, y2, w)
|
||||
}
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
|
||||
// ── Draw nodes (over edges) ───────────────────────────────────────────────
|
||||
let node_count: Int = json_array_len(nodes_json)
|
||||
let j: Int = 0
|
||||
while j < node_count {
|
||||
let n: String = json_array_get(nodes_json, j)
|
||||
let id: String = node_id(n)
|
||||
let lbl: String = node_label(n)
|
||||
let ntype: String = node_type_field(n)
|
||||
let sal: Float = node_salience(n)
|
||||
let color: String = node_color(ntype)
|
||||
let radius: Int = node_radius_int(sal)
|
||||
let pos: String = state_get("pos_" + id)
|
||||
if !str_eq(pos, "") {
|
||||
let x: Float = get_pos_x(id)
|
||||
let y: Float = get_pos_y(id)
|
||||
let out = out + svg_node(x, y, radius, color, lbl)
|
||||
}
|
||||
let j = j + 1
|
||||
}
|
||||
|
||||
let out = out + svg_close()
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// el-i18n — Localization for el-ui.
|
||||
//
|
||||
// RTL-aware, plural forms, CLDR-based number / currency formatting.
|
||||
|
||||
vessel "el-i18n" {
|
||||
version "0.1.0"
|
||||
description "Locales, translations, plural forms, number/currency formatting"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// el-i18n — Localization for el-ui.
|
||||
//
|
||||
// Two-letter language tag + optional region: en, en-US, ar-EG, zh-Hant.
|
||||
// Bundles map keys to either a plain string or a plural-form map.
|
||||
// `t(key)` and `t_plural(key, count)` are the surface API.
|
||||
|
||||
// ── Locale ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let DIR_LTR: String = "ltr"
|
||||
let DIR_RTL: String = "rtl"
|
||||
|
||||
type Locale {
|
||||
language: String // "en"
|
||||
region: String // "US" (may be empty)
|
||||
direction: String // "ltr" | "rtl"
|
||||
}
|
||||
|
||||
fn locale_new(language: String, region: String) -> Locale {
|
||||
let dir: String = "ltr"
|
||||
if str_eq(language, "ar") { let dir = "rtl" }
|
||||
if str_eq(language, "he") { let dir = "rtl" }
|
||||
if str_eq(language, "fa") { let dir = "rtl" }
|
||||
if str_eq(language, "ur") { let dir = "rtl" }
|
||||
{ "language": language, "region": region, "direction": dir }
|
||||
}
|
||||
|
||||
fn locale_en_us() -> Locale { locale_new("en", "US") }
|
||||
fn locale_es_es() -> Locale { locale_new("es", "ES") }
|
||||
fn locale_zh_cn() -> Locale { locale_new("zh", "CN") }
|
||||
fn locale_ar_eg() -> Locale { locale_new("ar", "EG") }
|
||||
fn locale_ja_jp() -> Locale { locale_new("ja", "JP") }
|
||||
|
||||
fn locale_tag(loc: Locale) -> String {
|
||||
if str_eq(loc.region, "") { return loc.language }
|
||||
loc.language + "-" + loc.region
|
||||
}
|
||||
|
||||
fn is_rtl(loc: Locale) -> Bool {
|
||||
str_eq(loc.direction, "rtl")
|
||||
}
|
||||
|
||||
// ── Plural forms (CLDR cardinal categories) ──────────────────────────────────
|
||||
//
|
||||
// Categories: zero, one, two, few, many, other.
|
||||
// Most languages only use one + other; Arabic uses all six.
|
||||
|
||||
let PLURAL_ZERO: String = "zero"
|
||||
let PLURAL_ONE: String = "one"
|
||||
let PLURAL_TWO: String = "two"
|
||||
let PLURAL_FEW: String = "few"
|
||||
let PLURAL_MANY: String = "many"
|
||||
let PLURAL_OTHER: String = "other"
|
||||
|
||||
fn plural_form(loc: Locale, n: Int) -> String {
|
||||
if str_eq(loc.language, "ar") { return plural_form_arabic(n) }
|
||||
if str_eq(loc.language, "ru") { return plural_form_russian(n) }
|
||||
if str_eq(loc.language, "pl") { return plural_form_polish(n) }
|
||||
if str_eq(loc.language, "ja") { return PLURAL_OTHER }
|
||||
if str_eq(loc.language, "zh") { return PLURAL_OTHER }
|
||||
if str_eq(loc.language, "ko") { return PLURAL_OTHER }
|
||||
// Default English-like rule
|
||||
if n == 1 { return PLURAL_ONE }
|
||||
PLURAL_OTHER
|
||||
}
|
||||
|
||||
fn plural_form_arabic(n: Int) -> String {
|
||||
if n == 0 { return PLURAL_ZERO }
|
||||
if n == 1 { return PLURAL_ONE }
|
||||
if n == 2 { return PLURAL_TWO }
|
||||
let mod100: Int = n - ((n / 100) * 100)
|
||||
if mod100 >= 3 {
|
||||
if mod100 <= 10 { return PLURAL_FEW }
|
||||
}
|
||||
if mod100 >= 11 {
|
||||
if mod100 <= 99 { return PLURAL_MANY }
|
||||
}
|
||||
PLURAL_OTHER
|
||||
}
|
||||
|
||||
fn plural_form_russian(n: Int) -> String {
|
||||
let mod10: Int = n - ((n / 10) * 10)
|
||||
let mod100: Int = n - ((n / 100) * 100)
|
||||
if mod10 == 1 {
|
||||
if mod100 == 11 { return PLURAL_MANY }
|
||||
return PLURAL_ONE
|
||||
}
|
||||
if mod10 >= 2 {
|
||||
if mod10 <= 4 {
|
||||
if mod100 >= 12 {
|
||||
if mod100 <= 14 { return PLURAL_MANY }
|
||||
}
|
||||
return PLURAL_FEW
|
||||
}
|
||||
}
|
||||
PLURAL_MANY
|
||||
}
|
||||
|
||||
fn plural_form_polish(n: Int) -> String {
|
||||
if n == 1 { return PLURAL_ONE }
|
||||
let mod10: Int = n - ((n / 10) * 10)
|
||||
let mod100: Int = n - ((n / 100) * 100)
|
||||
if mod10 >= 2 {
|
||||
if mod10 <= 4 {
|
||||
if mod100 >= 12 {
|
||||
if mod100 <= 14 { return PLURAL_MANY }
|
||||
}
|
||||
return PLURAL_FEW
|
||||
}
|
||||
}
|
||||
PLURAL_MANY
|
||||
}
|
||||
|
||||
// ── Translation bundle ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Stored as a JSON map: key -> value | { one: "...", other: "..." }
|
||||
// `bundle_load_toml` parses a TOML file at load time (planned runtime fn).
|
||||
|
||||
fn bundle_new() -> String {
|
||||
"{}"
|
||||
}
|
||||
|
||||
fn bundle_insert(bundle: String, key: String, value: String) -> String {
|
||||
json_set(bundle, key, "\"" + value + "\"")
|
||||
}
|
||||
|
||||
fn bundle_insert_plural(bundle: String, key: String, plural_map_json: String) -> String {
|
||||
json_set(bundle, key, plural_map_json)
|
||||
}
|
||||
|
||||
fn bundle_load_toml(path: String) -> String {
|
||||
let raw: String = fs_read(path)
|
||||
toml_to_json(raw)
|
||||
}
|
||||
|
||||
// ── LocaleContext + t/t_plural ──────────────────────────────────────────────
|
||||
|
||||
type LocaleContext {
|
||||
locale: Locale
|
||||
bundle: String // JSON
|
||||
fallback_bundle: String
|
||||
}
|
||||
|
||||
fn locale_context_new(loc: Locale, bundle: String) -> LocaleContext {
|
||||
{ "locale": loc, "bundle": bundle, "fallback_bundle": "{}" }
|
||||
}
|
||||
|
||||
fn t(ctx: LocaleContext, key: String) -> String {
|
||||
let v: String = json_get(ctx.bundle, key)
|
||||
if str_eq(v, "") { let v = json_get(ctx.fallback_bundle, key) }
|
||||
if str_eq(v, "") { return key }
|
||||
v
|
||||
}
|
||||
|
||||
fn t_plural(ctx: LocaleContext, key: String, n: Int) -> String {
|
||||
let entry: String = json_get(ctx.bundle, key)
|
||||
if str_eq(entry, "") { return key }
|
||||
let form: String = plural_form(ctx.locale, n)
|
||||
let template: String = json_get(entry, form)
|
||||
if str_eq(template, "") { let template = json_get(entry, PLURAL_OTHER) }
|
||||
if str_eq(template, "") { return key }
|
||||
str_replace(template, "{n}", int_to_str(n))
|
||||
}
|
||||
|
||||
// ── Number / currency formatting ─────────────────────────────────────────────
|
||||
|
||||
fn format_integer(loc: Locale, n: Int) -> String {
|
||||
// Group thousands by the locale's separator. Stub: en uses ',', most EU uses '.'.
|
||||
let sep: String = ","
|
||||
if str_eq(loc.language, "es") { let sep = "." }
|
||||
if str_eq(loc.language, "de") { let sep = "." }
|
||||
if str_eq(loc.language, "fr") { let sep = " " }
|
||||
int_with_separator(n, sep)
|
||||
}
|
||||
|
||||
fn format_number(loc: Locale, n: Int, fraction_digits: Int) -> String {
|
||||
let dec_sep: String = "."
|
||||
if str_eq(loc.language, "es") { let dec_sep = "," }
|
||||
if str_eq(loc.language, "de") { let dec_sep = "," }
|
||||
if str_eq(loc.language, "fr") { let dec_sep = "," }
|
||||
format_integer(loc, n) + dec_sep + repeat_str("0", fraction_digits)
|
||||
}
|
||||
|
||||
fn format_percent(loc: Locale, value_x100: Int) -> String {
|
||||
let body: String = int_to_str(value_x100 / 100) + "."
|
||||
+ int_to_str(value_x100 - ((value_x100 / 100) * 100))
|
||||
if str_eq(loc.language, "fr") { return body + " %" }
|
||||
body + "%"
|
||||
}
|
||||
|
||||
fn format_currency(loc: Locale, amount_minor: Int, iso: String) -> String {
|
||||
// amount_minor is in the smallest unit (cents). Stub formatting only.
|
||||
let major: Int = amount_minor / 100
|
||||
let minor: Int = amount_minor - (major * 100)
|
||||
let body: String = int_to_str(major) + "." + int_to_str(minor)
|
||||
if str_eq(iso, "USD") { return "$" + body }
|
||||
if str_eq(iso, "EUR") { return body + " €" }
|
||||
if str_eq(iso, "GBP") { return "£" + body }
|
||||
if str_eq(iso, "JPY") { return "¥" + int_to_str(amount_minor) }
|
||||
body + " " + iso
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let loc: Locale = locale_en_us()
|
||||
let bundle: String = bundle_new()
|
||||
let bundle = bundle_insert(bundle, "profile.follow", "Follow")
|
||||
let ctx: LocaleContext = locale_context_new(loc, bundle)
|
||||
println("[el-i18n] " + t(ctx, "profile.follow"))
|
||||
@@ -0,0 +1,24 @@
|
||||
// el-identity — Engram-native identity for el-ui.
|
||||
//
|
||||
// Identity is not a bolt-on. Users, roles, scopes, sessions, and OAuth tokens
|
||||
// are first-class Engram nodes connected by typed edges:
|
||||
//
|
||||
// User ──has_role──▶ Role ──grants──▶ Scope
|
||||
// │
|
||||
// └──has_session──▶ Session ──authenticated_via──▶ OAuthToken
|
||||
|
||||
vessel "el-identity" {
|
||||
version "0.1.0"
|
||||
description "Engram-native identity graph: users, roles, sessions, OAuth"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// el-identity — Engram-native identity for el-ui.
|
||||
//
|
||||
// Identity in el-ui is a graph, not a table. Every entity is a node; every
|
||||
// relationship is an edge. Authentication is spreading activation from a
|
||||
// session token through the identity subgraph until it touches a User node.
|
||||
//
|
||||
// Edges:
|
||||
// User ──has_role──▶ Role ──grants──▶ Scope
|
||||
// User ──has_session──▶ Session ──authenticated_via──▶ OAuthToken
|
||||
|
||||
// ── Edge type constants ──────────────────────────────────────────────────────
|
||||
|
||||
let EDGE_HAS_ROLE: String = "has_role"
|
||||
let EDGE_HAS_SESSION: String = "has_session"
|
||||
let EDGE_AUTHENTICATED_VIA: String = "authenticated_via"
|
||||
let EDGE_GRANTS: String = "grants"
|
||||
|
||||
// ── Node type constants ──────────────────────────────────────────────────────
|
||||
|
||||
let NODE_USER: String = "User"
|
||||
let NODE_ROLE: String = "Role"
|
||||
let NODE_SCOPE: String = "Scope"
|
||||
let NODE_OAUTH_TOKEN: String = "OAuthToken"
|
||||
let NODE_SESSION: String = "Session"
|
||||
|
||||
// ── User ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type User {
|
||||
id: String
|
||||
email: String
|
||||
display_name: String
|
||||
created_at: String
|
||||
}
|
||||
|
||||
fn user_new(email: String, display_name: String) -> User {
|
||||
let now: String = time_now_iso()
|
||||
let id: String = uuid_v4()
|
||||
{ "id": id, "email": email, "display_name": display_name, "created_at": now }
|
||||
}
|
||||
|
||||
// ── Role / Scope ─────────────────────────────────────────────────────────────
|
||||
|
||||
type Role {
|
||||
id: String
|
||||
name: String
|
||||
permissions: String // JSON-encoded array; struct fields are flat in El today
|
||||
}
|
||||
|
||||
type Scope {
|
||||
id: String
|
||||
name: String
|
||||
description: String
|
||||
}
|
||||
|
||||
fn role_new(name: String) -> Role {
|
||||
{ "id": uuid_v4(), "name": name, "permissions": "[]" }
|
||||
}
|
||||
|
||||
fn role_has_permission(role: Role, perm: String) -> Bool {
|
||||
str_contains(role.permissions, "\"" + perm + "\"")
|
||||
}
|
||||
|
||||
fn scope_new(name: String, description: String) -> Scope {
|
||||
{ "id": uuid_v4(), "name": name, "description": description }
|
||||
}
|
||||
|
||||
// ── Session ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Session {
|
||||
id: String
|
||||
user_id: String
|
||||
created_at: String
|
||||
expires_at: String
|
||||
ip_address: String
|
||||
}
|
||||
|
||||
fn session_new(user_id: String, ttl_seconds: Int, ip_address: String) -> Session {
|
||||
let now: String = time_now_iso()
|
||||
let exp: String = time_add_seconds(now, ttl_seconds)
|
||||
{ "id": uuid_v4(), "user_id": user_id, "created_at": now, "expires_at": exp, "ip_address": ip_address }
|
||||
}
|
||||
|
||||
fn session_is_expired(session: Session) -> Bool {
|
||||
time_after(time_now_iso(), session.expires_at)
|
||||
}
|
||||
|
||||
// ── OAuthToken ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Tokens are SHA-256 hashed before storage — the raw token never persists.
|
||||
|
||||
type OAuthToken {
|
||||
id: String
|
||||
provider: String
|
||||
access_token_hash: String
|
||||
refresh_token_hash: String
|
||||
expires_at: String
|
||||
scopes: String // JSON-encoded array
|
||||
}
|
||||
|
||||
fn token_hash(raw: String) -> String {
|
||||
sha256_hex(raw)
|
||||
}
|
||||
|
||||
fn oauth_token_new(provider: String, access_raw: String, refresh_raw: String, expires_at: String, scopes: String) -> OAuthToken {
|
||||
let access_h: String = token_hash(access_raw)
|
||||
let refresh_h: String = ""
|
||||
if !str_eq(refresh_raw, "") { let refresh_h = token_hash(refresh_raw) }
|
||||
{ "id": uuid_v4(), "provider": provider, "access_token_hash": access_h,
|
||||
"refresh_token_hash": refresh_h, "expires_at": expires_at, "scopes": scopes }
|
||||
}
|
||||
|
||||
fn oauth_token_is_expired(t: OAuthToken) -> Bool {
|
||||
time_after(time_now_iso(), t.expires_at)
|
||||
}
|
||||
|
||||
// ── PKCE (RFC 7636) ──────────────────────────────────────────────────────────
|
||||
|
||||
type PkceChallenge {
|
||||
verifier: String
|
||||
challenge: String
|
||||
method: String // always "S256"
|
||||
}
|
||||
|
||||
fn pkce_generate() -> PkceChallenge {
|
||||
let verifier: String = base64url_no_pad(random_bytes(32))
|
||||
let chal: String = base64url_no_pad(sha256_bytes(verifier))
|
||||
{ "verifier": verifier, "challenge": chal, "method": "S256" }
|
||||
}
|
||||
|
||||
fn pkce_verify(challenge: String, verifier: String) -> Bool {
|
||||
let computed: String = base64url_no_pad(sha256_bytes(verifier))
|
||||
str_eq(computed, challenge)
|
||||
}
|
||||
|
||||
// ── OAuth providers (Google / GitHub / Apple) ────────────────────────────────
|
||||
//
|
||||
// Stubbed: shape-only. `provider_authorization_url` produces the redirect URL;
|
||||
// `provider_exchange_code` POSTs to the token endpoint via http_post.
|
||||
|
||||
type OAuthProviderCfg {
|
||||
name: String
|
||||
client_id: String
|
||||
client_secret: String
|
||||
auth_url: String
|
||||
token_url: String
|
||||
default_scopes: String
|
||||
}
|
||||
|
||||
fn google_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
|
||||
{ "name": "google", "client_id": client_id, "client_secret": client_secret,
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"default_scopes": "[\"openid\",\"email\",\"profile\"]" }
|
||||
}
|
||||
|
||||
fn github_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
|
||||
{ "name": "github", "client_id": client_id, "client_secret": client_secret,
|
||||
"auth_url": "https://github.com/login/oauth/authorize",
|
||||
"token_url": "https://github.com/login/oauth/access_token",
|
||||
"default_scopes": "[\"read:user\",\"user:email\"]" }
|
||||
}
|
||||
|
||||
fn apple_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
|
||||
{ "name": "apple", "client_id": client_id, "client_secret": client_secret,
|
||||
"auth_url": "https://appleid.apple.com/auth/authorize",
|
||||
"token_url": "https://appleid.apple.com/auth/token",
|
||||
"default_scopes": "[\"name\",\"email\"]" }
|
||||
}
|
||||
|
||||
fn provider_authorization_url(p: OAuthProviderCfg, redirect_uri: String, code_challenge: String, state: String) -> String {
|
||||
p.auth_url + "?response_type=code"
|
||||
+ "&client_id=" + url_encode(p.client_id)
|
||||
+ "&redirect_uri=" + url_encode(redirect_uri)
|
||||
+ "&scope=" + url_encode(json_array_to_space_list(p.default_scopes))
|
||||
+ "&state=" + url_encode(state)
|
||||
+ "&code_challenge=" + url_encode(code_challenge)
|
||||
+ "&code_challenge_method=S256"
|
||||
}
|
||||
|
||||
// ── Engram client interface (graph CRUD) ─────────────────────────────────────
|
||||
//
|
||||
// Identity persists to the local Engram graph. These wrap engram_* runtime
|
||||
// calls (planned). Until that lands, the server-side stub uses a JSON file.
|
||||
|
||||
fn engram_create_node(node_type: String, value_json: String) -> String {
|
||||
engram_node_create(node_type, value_json)
|
||||
}
|
||||
|
||||
fn engram_create_edge(from_id: String, to_id: String, edge_type: String) -> Bool {
|
||||
engram_edge_create(from_id, to_id, edge_type)
|
||||
}
|
||||
|
||||
fn engram_find_connected(node_id: String, edge_type: String) -> String {
|
||||
engram_edge_traverse(node_id, edge_type)
|
||||
}
|
||||
|
||||
// ── OAuth flow coordinator ───────────────────────────────────────────────────
|
||||
|
||||
type AuthFlowParams {
|
||||
pkce_verifier: String
|
||||
pkce_challenge: String
|
||||
redirect_url: String
|
||||
state: String
|
||||
}
|
||||
|
||||
fn begin_auth_flow(provider: OAuthProviderCfg, redirect_uri: String) -> AuthFlowParams {
|
||||
let pkce: PkceChallenge = pkce_generate()
|
||||
let state: String = base64url_no_pad(random_bytes(16))
|
||||
let url: String = provider_authorization_url(provider, redirect_uri, pkce.challenge, state)
|
||||
{ "pkce_verifier": pkce.verifier, "pkce_challenge": pkce.challenge, "redirect_url": url, "state": state }
|
||||
}
|
||||
|
||||
fn exchange_code(provider: OAuthProviderCfg, code: String, pkce_verifier: String, redirect_uri: String, session_id: String) -> OAuthToken {
|
||||
let body: String = "grant_type=authorization_code"
|
||||
+ "&code=" + url_encode(code)
|
||||
+ "&redirect_uri=" + url_encode(redirect_uri)
|
||||
+ "&client_id=" + url_encode(provider.client_id)
|
||||
+ "&client_secret=" + url_encode(provider.client_secret)
|
||||
+ "&code_verifier=" + url_encode(pkce_verifier)
|
||||
let resp: String = http_post(provider.token_url, body)
|
||||
let access: String = json_get(resp, "access_token")
|
||||
let refresh: String = json_get(resp, "refresh_token")
|
||||
let expires_in: Int = str_to_int(json_get(resp, "expires_in"))
|
||||
let exp_at: String = time_add_seconds(time_now_iso(), expires_in)
|
||||
let token: OAuthToken = oauth_token_new(provider.name, access, refresh, exp_at, "[]")
|
||||
let token_id: String = engram_create_node(NODE_OAUTH_TOKEN, json_encode(token))
|
||||
engram_create_edge(session_id, token_id, EDGE_AUTHENTICATED_VIA)
|
||||
token
|
||||
}
|
||||
|
||||
// ── Session manager ──────────────────────────────────────────────────────────
|
||||
|
||||
fn session_create(user_id: String, ttl: Int, ip: String) -> Session {
|
||||
let s: Session = session_new(user_id, ttl, ip)
|
||||
let session_id: String = engram_create_node(NODE_SESSION, json_encode(s))
|
||||
engram_create_edge(user_id, session_id, EDGE_HAS_SESSION)
|
||||
s
|
||||
}
|
||||
|
||||
fn session_revoke(session_id: String) -> Bool {
|
||||
engram_node_delete(session_id)
|
||||
}
|
||||
|
||||
// ── AuthGuard — applied via @authenticate decorator ─────────────────────────
|
||||
|
||||
fn auth_guard_verify(session_id: String) -> Bool {
|
||||
let raw: String = engram_node_get(session_id)
|
||||
if str_eq(raw, "") { return false }
|
||||
let exp: String = json_get(raw, "expires_at")
|
||||
!time_after(time_now_iso(), exp)
|
||||
}
|
||||
|
||||
// ── Identity context (passed through the request lifecycle) ─────────────────
|
||||
|
||||
type IdentityContext {
|
||||
user_id: String
|
||||
session_id: String
|
||||
roles_json: String
|
||||
}
|
||||
|
||||
fn identity_load(session_id: String) -> IdentityContext {
|
||||
let session_raw: String = engram_node_get(session_id)
|
||||
let user_id: String = json_get(session_raw, "user_id")
|
||||
let roles_raw: String = engram_find_connected(user_id, EDGE_HAS_ROLE)
|
||||
{ "user_id": user_id, "session_id": session_id, "roles_json": roles_raw }
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let user: User = user_new("will@neurontechnologies.ai", "Will Anderson")
|
||||
println("[el-identity] user " + user.email + " (" + user.id + ")")
|
||||
@@ -0,0 +1,21 @@
|
||||
// el-layout — Responsive layout engine for el-ui.
|
||||
//
|
||||
// Responsive by default. VStack and HStack wrap automatically.
|
||||
// Grid uses auto columns. You don't write breakpoints for basic layouts.
|
||||
|
||||
vessel "el-layout" {
|
||||
version "0.1.0"
|
||||
description "Stacks, grids, breakpoints, responsive values, safe-area insets"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
el-style "0.1"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// el-layout — Responsive layout engine for el-ui.
|
||||
//
|
||||
// Primitives:
|
||||
// VStack, HStack, ZStack — stack layouts (wrap by default)
|
||||
// GridLayout — responsive grid
|
||||
// ScrollView — scrollable container
|
||||
// Responsive<T> — value that changes by breakpoint
|
||||
|
||||
// ── Breakpoints ──────────────────────────────────────────────────────────────
|
||||
|
||||
let BP_XS: String = "xs" // < 640px
|
||||
let BP_SM: String = "sm" // >= 640px
|
||||
let BP_MD: String = "md" // >= 768px
|
||||
let BP_LG: String = "lg" // >= 1024px
|
||||
let BP_XL: String = "xl" // >= 1280px
|
||||
let BP_XXL: String = "xxl" // >= 1536px
|
||||
|
||||
let BP_SM_PX: Int = 640
|
||||
let BP_MD_PX: Int = 768
|
||||
let BP_LG_PX: Int = 1024
|
||||
let BP_XL_PX: Int = 1280
|
||||
let BP_XXL_PX: Int = 1536
|
||||
|
||||
fn breakpoint_for_width(width_px: Int) -> String {
|
||||
if width_px >= BP_XXL_PX { return BP_XXL }
|
||||
if width_px >= BP_XL_PX { return BP_XL }
|
||||
if width_px >= BP_LG_PX { return BP_LG }
|
||||
if width_px >= BP_MD_PX { return BP_MD }
|
||||
if width_px >= BP_SM_PX { return BP_SM }
|
||||
BP_XS
|
||||
}
|
||||
|
||||
// Cascade index — used by Responsive<T> to find the most-specific value <= bp.
|
||||
fn breakpoint_index(bp: String) -> Int {
|
||||
if str_eq(bp, "xs") { return 0 }
|
||||
if str_eq(bp, "sm") { return 1 }
|
||||
if str_eq(bp, "md") { return 2 }
|
||||
if str_eq(bp, "lg") { return 3 }
|
||||
if str_eq(bp, "xl") { return 4 }
|
||||
if str_eq(bp, "xxl") { return 5 }
|
||||
0
|
||||
}
|
||||
|
||||
// ── Responsive<T> ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Stored as a JSON object: { "xs": v0, "md": v1, "lg": v2 }
|
||||
// resolve(bp) walks down from bp until it finds a defined value.
|
||||
|
||||
fn responsive_fixed(value: String) -> String {
|
||||
"{\"xs\":" + value + "}"
|
||||
}
|
||||
|
||||
fn responsive_set(rv: String, bp: String, value: String) -> String {
|
||||
json_set(rv, bp, value)
|
||||
}
|
||||
|
||||
fn responsive_resolve(rv: String, bp: String) -> String {
|
||||
let order: String = "xxl,xl,lg,md,sm,xs"
|
||||
let target_idx: Int = breakpoint_index(bp)
|
||||
// Try each breakpoint <= target, most-specific first.
|
||||
let probe: String = bp
|
||||
while !str_eq(probe, "") {
|
||||
let v: String = json_get(rv, probe)
|
||||
if !str_eq(v, "") { return v }
|
||||
let idx: Int = breakpoint_index(probe)
|
||||
if idx == 0 { return "" }
|
||||
let probe = breakpoint_step_down(probe)
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
fn breakpoint_step_down(bp: String) -> String {
|
||||
if str_eq(bp, "xxl") { return "xl" }
|
||||
if str_eq(bp, "xl") { return "lg" }
|
||||
if str_eq(bp, "lg") { return "md" }
|
||||
if str_eq(bp, "md") { return "sm" }
|
||||
if str_eq(bp, "sm") { return "xs" }
|
||||
""
|
||||
}
|
||||
|
||||
// ── Constraints / Size ───────────────────────────────────────────────────────
|
||||
|
||||
type Size {
|
||||
width: Int
|
||||
height: Int
|
||||
}
|
||||
|
||||
type LayoutConstraints {
|
||||
min_width: Int
|
||||
max_width: Int
|
||||
min_height: Int
|
||||
max_height: Int
|
||||
}
|
||||
|
||||
fn constraints_unbounded() -> LayoutConstraints {
|
||||
{ "min_width": 0, "max_width": 999999, "min_height": 0, "max_height": 999999 }
|
||||
}
|
||||
|
||||
fn constraints_tight(w: Int, h: Int) -> LayoutConstraints {
|
||||
{ "min_width": w, "max_width": w, "min_height": h, "max_height": h }
|
||||
}
|
||||
|
||||
// ── Flex axes ────────────────────────────────────────────────────────────────
|
||||
|
||||
let FLEX_ROW: String = "row"
|
||||
let FLEX_COLUMN: String = "column"
|
||||
|
||||
let MAIN_START: String = "start"
|
||||
let MAIN_END: String = "end"
|
||||
let MAIN_CENTER: String = "center"
|
||||
let MAIN_BETWEEN: String = "space-between"
|
||||
let MAIN_AROUND: String = "space-around"
|
||||
let MAIN_EVENLY: String = "space-evenly"
|
||||
|
||||
let CROSS_START: String = "start"
|
||||
let CROSS_END: String = "end"
|
||||
let CROSS_CENTER: String = "center"
|
||||
let CROSS_STRETCH: String = "stretch"
|
||||
let CROSS_BASELINE: String = "baseline"
|
||||
|
||||
type FlexLayout {
|
||||
direction: String
|
||||
main_alignment: String
|
||||
cross_alignment: String
|
||||
gap_px: Int
|
||||
wrap: Bool
|
||||
}
|
||||
|
||||
fn flex_default() -> FlexLayout {
|
||||
{ "direction": "row", "main_alignment": "start",
|
||||
"cross_alignment": "stretch", "gap_px": 8, "wrap": true }
|
||||
}
|
||||
|
||||
// ── Stacks ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// VStack / HStack / ZStack are component classes in the JS runtime; the
|
||||
// El side here exposes their layout descriptor — the bag of values the
|
||||
// el-ui-compiler emits into JSX/HTML attributes.
|
||||
|
||||
type StackLayout {
|
||||
direction: String // row | column | depth
|
||||
main_alignment: String
|
||||
cross_alignment: String
|
||||
gap_px: Int
|
||||
wrap: Bool
|
||||
spacing_token: String // semantic spacing token (md, lg, ...)
|
||||
}
|
||||
|
||||
fn vstack(spacing_token: String) -> StackLayout {
|
||||
{ "direction": "column", "main_alignment": "start",
|
||||
"cross_alignment": "stretch", "gap_px": 0, "wrap": true,
|
||||
"spacing_token": spacing_token }
|
||||
}
|
||||
|
||||
fn hstack(spacing_token: String) -> StackLayout {
|
||||
{ "direction": "row", "main_alignment": "start",
|
||||
"cross_alignment": "center", "gap_px": 0, "wrap": true,
|
||||
"spacing_token": spacing_token }
|
||||
}
|
||||
|
||||
fn zstack() -> StackLayout {
|
||||
{ "direction": "depth", "main_alignment": "center",
|
||||
"cross_alignment": "center", "gap_px": 0, "wrap": false,
|
||||
"spacing_token": "" }
|
||||
}
|
||||
|
||||
// ── Grid ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type GridLayout {
|
||||
columns: String // "auto" or "1fr 1fr 1fr" etc
|
||||
rows: String
|
||||
gap_px: Int
|
||||
auto_fit_min: Int // for `repeat(auto-fit, minmax(<min>, 1fr))`
|
||||
}
|
||||
|
||||
fn grid_auto(min_col_px: Int, gap: Int) -> GridLayout {
|
||||
{ "columns": "auto-fit", "rows": "auto", "gap_px": gap, "auto_fit_min": min_col_px }
|
||||
}
|
||||
|
||||
fn grid_fixed(num_cols: Int, gap: Int) -> GridLayout {
|
||||
let cols: String = repeat_str("1fr ", num_cols)
|
||||
{ "columns": cols, "rows": "auto", "gap_px": gap, "auto_fit_min": 0 }
|
||||
}
|
||||
|
||||
fn grid_to_css(g: GridLayout) -> String {
|
||||
let cols: String = g.columns
|
||||
if str_eq(cols, "auto-fit") {
|
||||
let cols = "repeat(auto-fit, minmax(" + int_to_str(g.auto_fit_min) + "px, 1fr))"
|
||||
}
|
||||
"display: grid; grid-template-columns: " + cols + "; gap: " + int_to_str(g.gap_px) + "px;"
|
||||
}
|
||||
|
||||
// ── ScrollView ──────────────────────────────────────────────────────────────
|
||||
|
||||
let SCROLL_X: String = "x"
|
||||
let SCROLL_Y: String = "y"
|
||||
let SCROLL_BOTH: String = "both"
|
||||
|
||||
type ScrollView {
|
||||
axis: String
|
||||
show_indicator: Bool
|
||||
bounce: Bool // iOS-style overscroll
|
||||
}
|
||||
|
||||
fn scroll_view_y() -> ScrollView {
|
||||
{ "axis": "y", "show_indicator": true, "bounce": true }
|
||||
}
|
||||
|
||||
// ── Platform sizing ──────────────────────────────────────────────────────────
|
||||
|
||||
let PLATFORM_PHONE: String = "phone"
|
||||
let PLATFORM_TABLET: String = "tablet"
|
||||
let PLATFORM_DESKTOP: String = "desktop"
|
||||
|
||||
type SafeAreaInsets {
|
||||
top: Int
|
||||
right: Int
|
||||
bottom: Int
|
||||
left: Int
|
||||
}
|
||||
|
||||
fn safe_area_zero() -> SafeAreaInsets {
|
||||
{ "top": 0, "right": 0, "bottom": 0, "left": 0 }
|
||||
}
|
||||
|
||||
fn platform_for_width(width: Int) -> String {
|
||||
if width < 768 { return "phone" }
|
||||
if width < 1024 { return "tablet" }
|
||||
"desktop"
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let r: String = responsive_fixed("1")
|
||||
let r = responsive_set(r, "md", "2")
|
||||
let r = responsive_set(r, "lg", "3")
|
||||
println("[el-layout] cols at lg = " + responsive_resolve(r, "lg"))
|
||||
@@ -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/"
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
//! 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 `el.toml`) differs.
|
||||
//! backend (chosen by `manifest.el`) differs.
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Platform configuration — parsed from `el.toml`.
|
||||
//! Platform configuration — parsed from `manifest.el`.
|
||||
|
||||
/// Which platform to target for rendering.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -20,7 +20,7 @@ pub enum PlatformTarget {
|
||||
}
|
||||
|
||||
impl PlatformTarget {
|
||||
/// Parse from the string value used in `el.toml`.
|
||||
/// 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),
|
||||
@@ -53,7 +53,7 @@ impl PlatformTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Full platform configuration, reflecting the `[platform]` section of `el.toml`.
|
||||
/// Full platform configuration, reflecting the `[platform]` section of `manifest.el`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformConfig {
|
||||
pub target: PlatformTarget,
|
||||
@@ -3,7 +3,7 @@
|
||||
//! The same component code produces native output for every target platform.
|
||||
//! No bridge. No virtual DOM. Direct platform calls.
|
||||
//!
|
||||
//! The target is chosen in `el.toml`:
|
||||
//! The target is chosen in `manifest.el`:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [platform]
|
||||
@@ -50,7 +50,7 @@ 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 `el.toml` differs.
|
||||
/// 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;
|
||||
@@ -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())
|
||||
@@ -0,0 +1,25 @@
|
||||
// el-publish — App Store and Play Store publishing pipeline.
|
||||
//
|
||||
// One command ships to every platform:
|
||||
// el publish all platforms
|
||||
// el publish --apple App Store only
|
||||
// el publish --google Play Store only
|
||||
// el publish --beta TestFlight + Play internal track
|
||||
|
||||
vessel "el-publish" {
|
||||
version "0.1.0"
|
||||
description "Apple App Store + Google Play publishing automation"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
el-config "0.1"
|
||||
el-secrets "0.1"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -23,7 +23,7 @@ pub struct ApplePublisher {
|
||||
impl ApplePublisher {
|
||||
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
|
||||
let apple_config = publish_config.apple.clone().ok_or_else(|| {
|
||||
PublishError::Config("no [publish.apple] section in el.toml".into())
|
||||
PublishError::Config("no [publish.apple] section in manifest.el".into())
|
||||
})?;
|
||||
Ok(Self {
|
||||
apple_config,
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Publish configuration — parsed from the `[publish]` section of `el.toml`.
|
||||
//! Publish configuration — parsed from the `[publish]` section of `manifest.el`.
|
||||
|
||||
/// App Store Connect / TestFlight configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -19,7 +19,7 @@ pub struct GooglePublisher {
|
||||
impl GooglePublisher {
|
||||
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
|
||||
let google_config = publish_config.google.clone().ok_or_else(|| {
|
||||
PublishError::Config("no [publish.google] section in el.toml".into())
|
||||
PublishError::Config("no [publish.google] section in manifest.el".into())
|
||||
})?;
|
||||
Ok(Self { google_config, publish_config })
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
//! el publish --beta # TestFlight + Play internal track
|
||||
//! ```
|
||||
//!
|
||||
//! Configuration in `el.toml`:
|
||||
//! Configuration in `manifest.el`:
|
||||
//! ```toml
|
||||
//! [publish]
|
||||
//! version = "1.0.0"
|
||||
@@ -0,0 +1,243 @@
|
||||
// el-publish — App Store + Play Store publishing pipeline.
|
||||
//
|
||||
// Two providers (Apple, Google), one publish flow:
|
||||
// 1. Validate config + certs
|
||||
// 2. Build artifact (delegated to el-ui-compiler / xcodebuild / gradle)
|
||||
// 3. Capture metadata + screenshots
|
||||
// 4. Upload to App Store Connect / Play Developer API
|
||||
// 5. Monitor rollout
|
||||
|
||||
// ── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let PUB_ERR_CONFIG: String = "publish.config"
|
||||
let PUB_ERR_BUILD: String = "publish.build"
|
||||
let PUB_ERR_UPLOAD: String = "publish.upload"
|
||||
let PUB_ERR_CERT: String = "publish.certificate"
|
||||
let PUB_ERR_METADATA: String = "publish.metadata"
|
||||
let PUB_ERR_API: String = "publish.api"
|
||||
let PUB_ERR_IO: String = "publish.io"
|
||||
|
||||
// ── Tracks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let TRACK_PRODUCTION: String = "production"
|
||||
let TRACK_BETA: String = "beta" // TestFlight on Apple; "beta" on Google
|
||||
let TRACK_INTERNAL: String = "internal"
|
||||
let TRACK_ALPHA: String = "alpha"
|
||||
|
||||
// ── Apple config ─────────────────────────────────────────────────────────────
|
||||
|
||||
type AppleConfig {
|
||||
account: String // Apple ID
|
||||
bundle_id: String // ai.neurontechnologies.myapp
|
||||
team_id: String
|
||||
api_key_id: String // App Store Connect API key
|
||||
api_issuer: String
|
||||
}
|
||||
|
||||
// ── Google config ────────────────────────────────────────────────────────────
|
||||
|
||||
type GoogleConfig {
|
||||
package: String // ai.neurontechnologies.myapp
|
||||
track: String // production | beta | internal | alpha
|
||||
service_account_json_ref: String // secret ref to service-account JSON
|
||||
}
|
||||
|
||||
// ── Rollout (staged) ─────────────────────────────────────────────────────────
|
||||
|
||||
type RolloutConfig {
|
||||
initial_percent: Int // 0..100
|
||||
target_percent: Int
|
||||
bake_hours: Int // hours between rollout stages
|
||||
}
|
||||
|
||||
fn rollout_default() -> RolloutConfig {
|
||||
{ "initial_percent": 5, "target_percent": 100, "bake_hours": 24 }
|
||||
}
|
||||
|
||||
// ── Top-level publish config ────────────────────────────────────────────────
|
||||
|
||||
type PublishConfig {
|
||||
version: String
|
||||
build_number: Int
|
||||
apple_json: String // empty if not configured
|
||||
google_json: String
|
||||
rollout_json: String // RolloutConfig as JSON
|
||||
}
|
||||
|
||||
fn publish_config_new(version: String, build_number: Int) -> PublishConfig {
|
||||
{ "version": version, "build_number": build_number,
|
||||
"apple_json": "", "google_json": "", "rollout_json": json_encode(rollout_default()) }
|
||||
}
|
||||
|
||||
// ── Outcome ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type PublishOutcome {
|
||||
platform: String
|
||||
version: String
|
||||
build_number: Int
|
||||
track: String
|
||||
rollout_percent: Int
|
||||
submission_id: String
|
||||
}
|
||||
|
||||
fn outcome_new(platform: String, cfg: PublishConfig, track: String) -> PublishOutcome {
|
||||
let pct: Int = 100
|
||||
if !str_eq(cfg.rollout_json, "") {
|
||||
let pct = str_to_int(json_get(cfg.rollout_json, "initial_percent"))
|
||||
}
|
||||
{ "platform": platform, "version": cfg.version, "build_number": cfg.build_number,
|
||||
"track": track, "rollout_percent": pct, "submission_id": "" }
|
||||
}
|
||||
|
||||
// ── Cert store ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Tracks .p12 / .mobileprovision (Apple) and signing keystores (Google).
|
||||
|
||||
type CertInfo {
|
||||
kind: String // "apple_p12" | "apple_provisioning" | "google_keystore"
|
||||
path: String
|
||||
expires_at: String // ISO 8601
|
||||
fingerprint: String
|
||||
}
|
||||
|
||||
fn cert_is_valid(c: CertInfo) -> Bool {
|
||||
!time_after(time_now_iso(), c.expires_at)
|
||||
}
|
||||
|
||||
fn cert_store_load(dir: String) -> String {
|
||||
// Returns JSON array of CertInfo loaded from `dir`.
|
||||
let files: String = fs_list(dir)
|
||||
let out: String = "[]"
|
||||
let n: Int = json_array_len(files)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let path: String = json_array_get(files, i)
|
||||
let info: CertInfo = cert_inspect(path)
|
||||
let out = json_array_push(out, json_encode(info))
|
||||
let i = i + 1
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cert_inspect(path: String) -> CertInfo {
|
||||
let kind: String = "apple_p12"
|
||||
if str_ends_with(path, ".mobileprovision") { let kind = "apple_provisioning" }
|
||||
if str_ends_with(path, ".jks") { let kind = "google_keystore" }
|
||||
if str_ends_with(path, ".keystore") { let kind = "google_keystore" }
|
||||
let exp: String = exec_capture("openssl", "x509 -enddate -noout -in " + path)
|
||||
{ "kind": kind, "path": path, "expires_at": exp, "fingerprint": "" }
|
||||
}
|
||||
|
||||
// ── Metadata ────────────────────────────────────────────────────────────────
|
||||
|
||||
type StoreMetadata {
|
||||
title: String
|
||||
subtitle: String
|
||||
description: String
|
||||
keywords: String // comma-separated
|
||||
privacy_url: String
|
||||
support_url: String
|
||||
locale: String // e.g. "en-US"
|
||||
}
|
||||
|
||||
fn metadata_load(path: String, locale: String) -> StoreMetadata {
|
||||
let raw: String = fs_read(path + "/" + locale + ".toml")
|
||||
let json: String = toml_to_json(raw)
|
||||
{ "title": json_get(json, "title"),
|
||||
"subtitle": json_get(json, "subtitle"),
|
||||
"description": json_get(json, "description"),
|
||||
"keywords": json_get(json, "keywords"),
|
||||
"privacy_url": json_get(json, "privacy_url"),
|
||||
"support_url": json_get(json, "support_url"),
|
||||
"locale": locale }
|
||||
}
|
||||
|
||||
// ── Screenshots ─────────────────────────────────────────────────────────────
|
||||
|
||||
type ScreenshotTarget {
|
||||
platform: String // "apple" | "google"
|
||||
device_class: String // "iphone-6.7" | "ipad-12.9" | "phone" | "tablet" | "tv"
|
||||
locale: String
|
||||
expected_resolution: String // "1290x2796"
|
||||
}
|
||||
|
||||
fn target_iphone_6_7(locale: String) -> ScreenshotTarget {
|
||||
{ "platform": "apple", "device_class": "iphone-6.7",
|
||||
"locale": locale, "expected_resolution": "1290x2796" }
|
||||
}
|
||||
|
||||
fn target_phone_google(locale: String) -> ScreenshotTarget {
|
||||
{ "platform": "google", "device_class": "phone",
|
||||
"locale": locale, "expected_resolution": "1080x1920" }
|
||||
}
|
||||
|
||||
fn screenshot_capture(target: ScreenshotTarget, simulator_id: String, output_dir: String) -> String {
|
||||
// Drives simulator/emulator to capture at expected resolution.
|
||||
let path: String = output_dir + "/" + target.platform + "_" + target.device_class
|
||||
+ "_" + target.locale + ".png"
|
||||
exec_capture("xcrun", "simctl io " + simulator_id + " screenshot " + path)
|
||||
path
|
||||
}
|
||||
|
||||
// ── Apple publisher ─────────────────────────────────────────────────────────
|
||||
|
||||
fn apple_publish(cfg: PublishConfig, apple: AppleConfig, ipa_path: String, track: String) -> PublishOutcome {
|
||||
let api_key: String = secret_lookup("apple.api_key")
|
||||
let resp: String = exec_capture("xcrun", "altool --upload-app -f " + ipa_path
|
||||
+ " --type ios --apiKey " + apple.api_key_id
|
||||
+ " --apiIssuer " + apple.api_issuer)
|
||||
let outcome: PublishOutcome = outcome_new("apple", cfg, track)
|
||||
let submission: String = json_get(resp, "submission_id")
|
||||
{ "platform": outcome.platform, "version": outcome.version,
|
||||
"build_number": outcome.build_number, "track": outcome.track,
|
||||
"rollout_percent": outcome.rollout_percent, "submission_id": submission }
|
||||
}
|
||||
|
||||
// ── Google publisher ────────────────────────────────────────────────────────
|
||||
|
||||
fn google_publish(cfg: PublishConfig, google: GoogleConfig, aab_path: String) -> PublishOutcome {
|
||||
let sa_json: String = secret_lookup(google.service_account_json_ref)
|
||||
let token: String = google_oauth_token(sa_json)
|
||||
let edit: String = google_play_edit_create(google.package, token)
|
||||
let upload: String = google_play_upload_aab(google.package, edit, aab_path, token)
|
||||
let assigned: String = google_play_track_assign(google.package, edit, google.track,
|
||||
cfg.version, cfg.build_number, token)
|
||||
google_play_edit_commit(google.package, edit, token)
|
||||
let outcome: PublishOutcome = outcome_new("google", cfg, google.track)
|
||||
{ "platform": outcome.platform, "version": outcome.version,
|
||||
"build_number": outcome.build_number, "track": outcome.track,
|
||||
"rollout_percent": outcome.rollout_percent, "submission_id": json_get(upload, "id") }
|
||||
}
|
||||
|
||||
// ── Rollout monitor ─────────────────────────────────────────────────────────
|
||||
|
||||
fn rollout_monitor(outcome: PublishOutcome, cfg: RolloutConfig) -> Bool {
|
||||
// Polls store status, advances rollout percentage in stages.
|
||||
let current: Int = outcome.rollout_percent
|
||||
while current < cfg.target_percent {
|
||||
sleep_seconds(cfg.bake_hours * 3600)
|
||||
let crash_rate: Int = fetch_crash_rate(outcome.platform, outcome.version)
|
||||
if crash_rate > 100 { // 1% crash threshold (per 10000)
|
||||
println("[el-publish] rollout halted: crash rate too high")
|
||||
return false
|
||||
}
|
||||
let next: Int = current + 25
|
||||
if next > cfg.target_percent { let next = cfg.target_percent }
|
||||
rollout_advance(outcome, next)
|
||||
let current = next
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn rollout_advance(outcome: PublishOutcome, percent: Int) -> Bool {
|
||||
println("[el-publish] " + outcome.platform + " rollout -> " + int_to_str(percent) + "%")
|
||||
true
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let cfg: PublishConfig = publish_config_new("1.0.0", 42)
|
||||
let apple: AppleConfig = { "account": "will@neurontechnologies.ai",
|
||||
"bundle_id": "ai.neurontechnologies.myapp", "team_id": "ABC123",
|
||||
"api_key_id": "KEY", "api_issuer": "ISS" }
|
||||
println("[el-publish] " + cfg.version + " (" + int_to_str(cfg.build_number) + ") for " + apple.bundle_id)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user