Merge PR #2: feat: port el-ui vessels — rename crates→vessels, add El source + manifests

This commit is contained in:
Will Anderson
2026-05-06 14:35:45 -05:00
145 changed files with 4048 additions and 12 deletions
+13 -12
View File
@@ -1,17 +1,18 @@
[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",
"vessels/el-html",
"examples/profile-card",
]
resolver = "2"
+22
View File
@@ -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/"
}
+223
View File
@@ -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)
+15
View File
@@ -0,0 +1,15 @@
vessel "el-auth" {
version "0.1.0"
description "Authentication and authorization: JWT, sessions, roles, permissions"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
+201
View File
@@ -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)
+15
View File
@@ -0,0 +1,15 @@
vessel "el-config" {
version "0.1.0"
description "Layered, typed configuration: env, dotenv, manifest, defaults"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
+177
View File
@@ -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"))
+17
View File
@@ -0,0 +1,17 @@
vessel "el-graph" {
version "0.1.0"
description "Graph rendering and editor vessel for el-ui"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
el-style "1.0"
el-layout "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
+46
View File
@@ -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)
}
+44
View File
@@ -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" }
+138
View File
@@ -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)
}
+352
View File
@@ -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
}
+34
View File
@@ -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) + "...")
+77
View File
@@ -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.01.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")
}
+80
View File
@@ -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)
}
+155
View File
@@ -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, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
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
}
+15
View File
@@ -0,0 +1,15 @@
vessel "el-html" {
version "0.1.0"
description "Atomic HTML emit primitives — server-side rendering building blocks"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
+222
View File
@@ -0,0 +1,222 @@
// el-html Atomic HTML element primitives.
//
// Usage:
// el_div("class=\"card\"", inner_html)
// el_h1("", "Hello world")
// el_img("/logo.png", "Logo", "width=\"120\"")
//
// Children args are raw HTML pre-escaped by caller.
// Use el_text(s) to safely insert plain text.
// Text escaping
fn el_escape(s: String) -> String {
let s = str_replace(s, "&", "&amp;")
let s = str_replace(s, "<", "&lt;")
let s = str_replace(s, ">", "&gt;")
let s = str_replace(s, "\"", "&quot;")
str_replace(s, "'", "&#39;")
}
fn el_text(s: String) -> String {
el_escape(s)
}
fn el_attr(name: String, value: String) -> String {
" " + name + "=\"" + el_escape(value) + "\""
}
// Block elements
fn el_div(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<div>" + children + "</div>" }
"<div " + attrs + ">" + children + "</div>"
}
fn el_section(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<section>" + children + "</section>" }
"<section " + attrs + ">" + children + "</section>"
}
fn el_article(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<article>" + children + "</article>" }
"<article " + attrs + ">" + children + "</article>"
}
fn el_header(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<header>" + children + "</header>" }
"<header " + attrs + ">" + children + "</header>"
}
fn el_footer(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<footer>" + children + "</footer>" }
"<footer " + attrs + ">" + children + "</footer>"
}
fn el_main(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<main>" + children + "</main>" }
"<main " + attrs + ">" + children + "</main>"
}
fn el_nav(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<nav>" + children + "</nav>" }
"<nav " + attrs + ">" + children + "</nav>"
}
fn el_aside(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<aside>" + children + "</aside>" }
"<aside " + attrs + ">" + children + "</aside>"
}
fn el_ul(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<ul>" + children + "</ul>" }
"<ul " + attrs + ">" + children + "</ul>"
}
fn el_ol(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<ol>" + children + "</ol>" }
"<ol " + attrs + ">" + children + "</ol>"
}
fn el_li(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<li>" + children + "</li>" }
"<li " + attrs + ">" + children + "</li>"
}
fn el_p(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<p>" + children + "</p>" }
"<p " + attrs + ">" + children + "</p>"
}
fn el_span(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<span>" + children + "</span>" }
"<span " + attrs + ">" + children + "</span>"
}
fn el_form(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<form>" + children + "</form>" }
"<form " + attrs + ">" + children + "</form>"
}
// Headings
fn el_h1(attrs: String, text: String) -> String {
if str_eq(attrs, "") { return "<h1>" + el_escape(text) + "</h1>" }
"<h1 " + attrs + ">" + el_escape(text) + "</h1>"
}
fn el_h2(attrs: String, text: String) -> String {
if str_eq(attrs, "") { return "<h2>" + el_escape(text) + "</h2>" }
"<h2 " + attrs + ">" + el_escape(text) + "</h2>"
}
fn el_h3(attrs: String, text: String) -> String {
if str_eq(attrs, "") { return "<h3>" + el_escape(text) + "</h3>" }
"<h3 " + attrs + ">" + el_escape(text) + "</h3>"
}
fn el_h4(attrs: String, text: String) -> String {
if str_eq(attrs, "") { return "<h4>" + el_escape(text) + "</h4>" }
"<h4 " + attrs + ">" + el_escape(text) + "</h4>"
}
// Interactive
fn el_button(attrs: String, label: String) -> String {
if str_eq(attrs, "") { return "<button type=\"button\">" + el_escape(label) + "</button>" }
"<button type=\"button\" " + attrs + ">" + el_escape(label) + "</button>"
}
fn el_a(href: String, attrs: String, children: String) -> String {
let h: String = "href=\"" + el_escape(href) + "\""
if str_eq(attrs, "") { return "<a " + h + ">" + children + "</a>" }
"<a " + h + " " + attrs + ">" + children + "</a>"
}
fn el_input(type_attr: String, attrs: String) -> String {
if str_eq(attrs, "") { return "<input type=\"" + type_attr + "\" />" }
"<input type=\"" + type_attr + "\" " + attrs + " />"
}
fn el_textarea(attrs: String, value: String) -> String {
if str_eq(attrs, "") { return "<textarea>" + el_escape(value) + "</textarea>" }
"<textarea " + attrs + ">" + el_escape(value) + "</textarea>"
}
fn el_label(for_id: String, attrs: String, children: String) -> String {
let f: String = "for=\"" + el_escape(for_id) + "\""
if str_eq(attrs, "") { return "<label " + f + ">" + children + "</label>" }
"<label " + f + " " + attrs + ">" + children + "</label>"
}
// Media
fn el_img(src: String, alt: String, attrs: String) -> String {
let base: String = "src=\"" + el_escape(src) + "\" alt=\"" + el_escape(alt) + "\""
if str_eq(attrs, "") { return "<img " + base + " />" }
"<img " + base + " " + attrs + " />"
}
fn el_video(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<video>" + children + "</video>" }
"<video " + attrs + ">" + children + "</video>"
}
// Inline semantic
fn el_strong(children: String) -> String {
"<strong>" + children + "</strong>"
}
fn el_em(children: String) -> String {
"<em>" + children + "</em>"
}
fn el_code(children: String) -> String {
"<code>" + children + "</code>"
}
fn el_pre(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<pre>" + children + "</pre>" }
"<pre " + attrs + ">" + children + "</pre>"
}
fn el_hr() -> String { "<hr />" }
fn el_br() -> String { "<br />" }
// Document shell
fn el_html_doc(lang: String, head_html: String, body_html: String) -> String {
"<!doctype html><html lang=\"" + el_escape(lang) + "\"><head>"
+ head_html + "</head><body>" + body_html + "</body></html>"
}
fn el_meta(name: String, content: String) -> String {
"<meta name=\"" + el_escape(name) + "\" content=\"" + el_escape(content) + "\" />"
}
fn el_meta_charset(charset: String) -> String {
"<meta charset=\"" + el_escape(charset) + "\" />"
}
fn el_link_stylesheet(href: String) -> String {
"<link rel=\"stylesheet\" href=\"" + el_escape(href) + "\" />"
}
fn el_script_src(src: String, defer_load: Bool) -> String {
if defer_load { return "<script src=\"" + el_escape(src) + "\" defer></script>" }
"<script src=\"" + el_escape(src) + "\"></script>"
}
fn el_script_inline(js: String) -> String {
"<script>" + js + "</script>"
}
fn el_title(text: String) -> String {
"<title>" + el_escape(text) + "</title>"
}
// Entry smoke test
let sample: String = el_div("class=\"card\"", el_h2("", "Hello") + el_p("", "World"))
println("[el-html] sample = " + sample)
+15
View File
@@ -0,0 +1,15 @@
vessel "el-i18n" {
version "0.1.0"
description "Localization: locale, plural forms, translation bundles, number/currency formatting"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
+208
View File
@@ -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"))
+16
View File
@@ -0,0 +1,16 @@
vessel "el-identity" {
version "0.1.0"
description "Engram-native identity: users, roles, sessions, OAuth PKCE flow"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
el-auth "0.1"
}
build {
entry "src/main.el"
output "dist/"
}
+271
View File
@@ -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 + ")")
+21
View File
@@ -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/"
}
+318
View File
@@ -0,0 +1,318 @@
// 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"
}
// HTML emit
//
// Server-side render: layout descriptor HTML string.
// Children is a pre-rendered HTML string passed in by the caller.
// class_extra is an optional additional CSS class string (pass "" for none).
fn stack_direction_to_css(direction: String) -> String {
if str_eq(direction, "column") { return "column" }
if str_eq(direction, "depth") { return "unset" }
"row"
}
fn stack_align_to_css(align: String) -> String {
if str_eq(align, "center") { return "center" }
if str_eq(align, "end") { return "flex-end" }
if str_eq(align, "baseline") { return "baseline" }
if str_eq(align, "stretch") { return "stretch" }
"flex-start"
}
fn stack_justify_to_css(align: String) -> String {
if str_eq(align, "center") { return "center" }
if str_eq(align, "end") { return "flex-end" }
if str_eq(align, "space-between") { return "space-between" }
if str_eq(align, "space-around") { return "space-around" }
if str_eq(align, "space-evenly") { return "space-evenly" }
"flex-start"
}
fn stack_to_html(layout: StackLayout, children: String, class_extra: String) -> String {
let direction: String = stack_direction_to_css(layout.direction)
let justify: String = stack_justify_to_css(layout.main_alignment)
let align: String = stack_align_to_css(layout.cross_alignment)
let gap: String = int_to_str(layout.gap_px) + "px"
let wrap_val: String = if layout.wrap { "wrap" } else { "nowrap" }
let style: String = "display:flex;flex-direction:" + direction
+ ";justify-content:" + justify
+ ";align-items:" + align
+ ";gap:" + gap
+ ";flex-wrap:" + wrap_val
let base_class: String = "el-stack el-stack--" + layout.direction
let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra }
"<div class=\"" + cls + "\" style=\"" + style + "\">" + children + "</div>"
}
fn vstack_to_html(spacing_px: Int, children: String, class_extra: String) -> String {
let layout: StackLayout = { "direction": "column", "main_alignment": "start",
"cross_alignment": "stretch", "gap_px": spacing_px, "wrap": true, "spacing_token": "" }
stack_to_html(layout, children, class_extra)
}
fn hstack_to_html(spacing_px: Int, children: String, class_extra: String) -> String {
let layout: StackLayout = { "direction": "row", "main_alignment": "start",
"cross_alignment": "center", "gap_px": spacing_px, "wrap": true, "spacing_token": "" }
stack_to_html(layout, children, class_extra)
}
fn zstack_to_html(children: String, class_extra: String) -> String {
let base_class: String = "el-stack el-stack--depth"
let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra }
"<div class=\"" + cls + "\" style=\"display:grid;place-items:center\">" + children + "</div>"
}
fn grid_to_html(layout: GridLayout, children: String, class_extra: String) -> String {
let css: String = grid_to_css(layout)
let base_class: String = "el-grid"
let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra }
"<div class=\"" + cls + "\" style=\"" + css + "\">" + children + "</div>"
}
fn scroll_to_html(layout: ScrollView, children: String, class_extra: String) -> String {
let overflow: String = if str_eq(layout.axis, "x") { "overflow-x:auto;overflow-y:hidden" }
else if str_eq(layout.axis, "both") { "overflow:auto" }
else { "overflow-x:hidden;overflow-y:auto" }
let base_class: String = "el-scroll"
let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra }
"<div class=\"" + cls + "\" style=\"" + overflow + "\">" + children + "</div>"
}
// 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"))
let v: String = vstack_to_html(16, "<p>hello</p>", "")
println("[el-layout] vstack = " + v)
+23
View File
@@ -0,0 +1,23 @@
// el-platform Platform abstraction surface for el-ui.
//
// Wraps the El runtime's filesystem, network, environment, and clock
// primitives behind a stable API so application vessels depend on this
// vessel rather than runtime builtin names directly.
//
// The Rust crate also implements per-target render backends
// (web/server/ios/android/macos/linux/windows). At the El layer the
// target is fixed at compile time the runtime IS the backend so no
// `PlatformBackend` polymorphism is exposed here. DOM patching and native
// widget mounting will arrive once el-ui-compiler emits browser/native code.
vessel "el-platform" {
version "1.0.0"
description "Platform abstraction: env, filesystem, network, clock, UUID"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
build {
entry "src/main.el"
output "dist/"
}
+152
View File
@@ -0,0 +1,152 @@
// el-platform Platform abstraction surface for el-ui apps.
//
// The Rust crate defines `PlatformBackend` trait + per-target render backends
// (web/server/ios/android/macos/linux/windows). The El surface is narrower:
// El apps reach the host through the runtime's filesystem, network, and OS
// primitives. This vessel wraps those into a stable, named API so El callers
// don't depend directly on builtin names.
//
// RUNTIME PARITY GAPS:
// - There is no `PlatformBackend` polymorphism at the El layer. The runtime
// IS the backend; target selection happens during compilation/packaging,
// not at runtime.
// - DOM mutation, native widget mounting, event binding none of those
// have El surfaces yet. Use el-ui-compiler when those land.
// - `fs_list` returns a JSON array string; consumers must parse with the
// forthcoming json_array_get builtin. Until that lands, callers should
// treat the value opaquely.
// Targets
fn target_web() -> String { "web" }
fn target_server() -> String { "server" }
fn target_ios() -> String { "ios" }
fn target_android() -> String { "android" }
fn target_macos() -> String { "macos" }
fn target_linux() -> String { "linux" }
fn target_windows() -> String { "windows" }
// The compiled binary's target is fixed at build time. EL_TARGET is set by
// the build harness; absent EL_TARGET, default to "server" (the El runtime
// runs as a host process).
fn platform_target() -> String {
let t: String = env("EL_TARGET")
if str_eq(t, "") { return "server" }
t
}
fn platform_is_native(t: String) -> Bool {
if str_eq(t, "ios") { return true }
if str_eq(t, "android") { return true }
if str_eq(t, "macos") { return true }
if str_eq(t, "linux") { return true }
if str_eq(t, "windows") { return true }
false
}
fn platform_supports_ssr(t: String) -> Bool {
str_eq(t, "server")
}
// Errors
fn err_io() -> String { "platform.io" }
fn err_network() -> String { "platform.network" }
fn err_unsupported() -> String { "platform.unsupported" }
fn err_env_missing() -> String { "platform.env_missing" }
// Environment
fn platform_env(key: String) -> String {
env(key)
}
fn platform_env_or(key: String, fallback: String) -> String {
let v: String = env(key)
if str_eq(v, "") { return fallback }
v
}
// Strict variant: returns "" if missing, callers branch.
// (Runtime has no panic() fail-soft return preserves type.)
fn platform_env_required(key: String) -> String {
let v: String = env(key)
if str_eq(v, "") {
println("[el-platform] ERROR " + err_env_missing() + ":" + key)
return ""
}
v
}
// Filesystem
fn platform_fs_read(path: String) -> String {
fs_read(path)
}
fn platform_fs_write(path: String, content: String) -> Bool {
fs_write(path, content)
true
}
// Returns a JSON array of entry names (opaque until json_array_get lands).
fn platform_fs_list(path: String) -> String {
let raw: String = fs_list(path)
if str_eq(raw, "") { return "[]" }
raw
}
fn platform_fs_exists(path: String) -> Bool {
let raw: String = fs_read(path)
!str_eq(raw, "")
}
// HTTP
fn platform_http_get(url: String) -> String {
http_get(url)
}
fn platform_http_post(url: String, body: String) -> String {
http_post(url, body)
}
fn platform_http_post_json(url: String, json_body: String) -> String {
http_post_json(url, json_body)
}
fn platform_http_get_authed(url: String, bearer: String) -> String {
let headers: String = "Authorization: Bearer " + bearer
http_get_with_headers(url, headers)
}
fn platform_http_post_authed(url: String, body: String, bearer: String) -> String {
let headers: String = "Authorization: Bearer " + bearer
http_post_with_headers(url, body, headers)
}
// Time / OS clock
fn platform_now() -> Int {
time_now()
}
fn platform_sleep_ms(ms: Int) -> Bool {
sleep_ms(ms)
true
}
// Identity (for trace/log correlation)
fn platform_uuid() -> String {
uuid_v4()
}
// Entry smoke test
//
// elc-bug-workaround: top-level `let` does not scope into the surrounding
// statement substitution in the entry block, so we call platform_target()
// inline at each use site.
println("[el-platform] target=" + platform_target() + " ssr=" + bool_to_str(platform_supports_ssr(platform_target())))
println("[el-platform] uuid=" + platform_uuid())
+25
View File
@@ -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/"
}
+243
View File
@@ -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