Merge PR #2: feat: port el-ui vessels — rename crates→vessels, add El source + manifests
This commit is contained in:
+13
-12
@@ -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"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// el-aop — Aspect-Oriented Programming for el-ui.
|
||||
//
|
||||
// Cross-cutting concerns as first-class language features. Decorators applied
|
||||
// to components and methods. @authenticate is the default; @public is the
|
||||
// explicit opt-out.
|
||||
|
||||
vessel "el-aop" {
|
||||
version "0.1.0"
|
||||
description "Decorators: @authenticate, @authorize, @cache, @rate_limit, ..."
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
el-auth "0.1"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// el-aop — Aspect-Oriented Programming for el-ui.
|
||||
//
|
||||
// Each aspect has three advice points: before, after, around. The aspect
|
||||
// chain is composed at compile time by the el-ui-compiler from decorators.
|
||||
//
|
||||
// The El runtime model uses tagged JSON for InvocationContext so an aspect
|
||||
// chain composes purely by passing the context map through each advice fn.
|
||||
//
|
||||
// Built-in aspects:
|
||||
// @authenticate — defaults on every component (security-by-default)
|
||||
// @public — opt-out marker
|
||||
// @authorize — role/permission gate
|
||||
// @cache — TTL-keyed memoization
|
||||
// @rate_limit — per-principal token bucket
|
||||
// @retry — retry on error with backoff
|
||||
// @log — structured log around invocation
|
||||
// @trace — emit OpenTelemetry-shaped spans
|
||||
// @validate — JSON schema check on args
|
||||
|
||||
// ── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let AOP_ERR_UNAUTHENTICATED: String = "aop.unauthenticated"
|
||||
let AOP_ERR_FORBIDDEN: String = "aop.forbidden"
|
||||
let AOP_ERR_RATE_LIMITED: String = "aop.rate_limited"
|
||||
let AOP_ERR_VALIDATION: String = "aop.validation_failed"
|
||||
let AOP_ERR_RETRIES_EXHAUSTED: String = "aop.retries_exhausted"
|
||||
|
||||
// ── Invocation context ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Stored as JSON: { target, method, args:{}, metadata:{} }
|
||||
// All advice mutates by returning a new ctx (functional style).
|
||||
|
||||
fn ctx_new(target: String, method: String) -> String {
|
||||
"{\"target\":\"" + target + "\",\"method\":\"" + method
|
||||
+ "\",\"args\":{},\"metadata\":{}}"
|
||||
}
|
||||
|
||||
fn ctx_with_arg(ctx: String, key: String, value: String) -> String {
|
||||
json_set_path(ctx, "args." + key, "\"" + value + "\"")
|
||||
}
|
||||
|
||||
fn ctx_with_meta(ctx: String, key: String, value: String) -> String {
|
||||
json_set_path(ctx, "metadata." + key, "\"" + value + "\"")
|
||||
}
|
||||
|
||||
fn ctx_get_meta(ctx: String, key: String) -> String {
|
||||
json_get_path(ctx, "metadata." + key)
|
||||
}
|
||||
|
||||
// ── Aspect dispatch table ────────────────────────────────────────────────────
|
||||
//
|
||||
// Each aspect is a triple of fn names: (before, around, after). The registry
|
||||
// is a map from aspect name -> { before, around, after } JSON.
|
||||
|
||||
fn registry_new() -> String {
|
||||
"{}"
|
||||
}
|
||||
|
||||
fn registry_register(reg: String, name: String, before_fn: String, around_fn: String, after_fn: String) -> String {
|
||||
let entry: String = "{\"before\":\"" + before_fn + "\",\"around\":\"" + around_fn
|
||||
+ "\",\"after\":\"" + after_fn + "\"}"
|
||||
json_set(reg, name, entry)
|
||||
}
|
||||
|
||||
// ── @authenticate — applied by default ───────────────────────────────────────
|
||||
|
||||
fn aspect_authenticate_before(ctx: String) -> String {
|
||||
let token: String = ctx_get_meta(ctx, "authorization")
|
||||
if str_eq(token, "") {
|
||||
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
|
||||
}
|
||||
let secret: String = env("JWT_SECRET")
|
||||
let auth_ctx: String = auth_middleware(token, secret)
|
||||
let user_id: String = json_get(auth_ctx, "user_id")
|
||||
if str_eq(user_id, "") {
|
||||
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
|
||||
}
|
||||
ctx_with_meta(ctx, "user_id", user_id)
|
||||
}
|
||||
|
||||
// ── @public — explicit opt-out marker ────────────────────────────────────────
|
||||
|
||||
fn aspect_public_before(ctx: String) -> String {
|
||||
ctx_with_meta(ctx, "public", "true")
|
||||
}
|
||||
|
||||
// ── @authorize(role) ─────────────────────────────────────────────────────────
|
||||
|
||||
fn aspect_authorize_before(ctx: String, required_role: String) -> String {
|
||||
let user_id: String = ctx_get_meta(ctx, "user_id")
|
||||
if str_eq(user_id, "") {
|
||||
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
|
||||
}
|
||||
let roles_json: String = engram_edge_traverse(user_id, "has_role")
|
||||
if !str_contains(roles_json, "\"" + required_role + "\"") {
|
||||
return ctx_with_meta(ctx, "error", AOP_ERR_FORBIDDEN)
|
||||
}
|
||||
ctx
|
||||
}
|
||||
|
||||
// ── @cache(ttl_seconds) ──────────────────────────────────────────────────────
|
||||
|
||||
fn aspect_cache_around(ctx: String, ttl: Int, proceed: String) -> String {
|
||||
let key: String = ctx_get_meta(ctx, "cache_key")
|
||||
if str_eq(key, "") {
|
||||
let target: String = json_get(ctx, "target")
|
||||
let method: String = json_get(ctx, "method")
|
||||
let args: String = json_get(ctx, "args")
|
||||
let key = sha256_hex(target + ":" + method + ":" + args)
|
||||
}
|
||||
let cached: String = cache_get(key)
|
||||
if !str_eq(cached, "") {
|
||||
return ctx_with_meta(ctx, "result", cached)
|
||||
}
|
||||
// Caller invokes proceed(ctx) externally; we record the key for `after` to use.
|
||||
ctx_with_meta(ctx, "cache_key", key)
|
||||
}
|
||||
|
||||
fn aspect_cache_after(ctx: String, result: String, ttl: Int) -> String {
|
||||
let key: String = ctx_get_meta(ctx, "cache_key")
|
||||
if !str_eq(key, "") { cache_put(key, result, ttl) }
|
||||
result
|
||||
}
|
||||
|
||||
// ── @rate_limit(requests, per_seconds) ───────────────────────────────────────
|
||||
|
||||
fn aspect_rate_limit_before(ctx: String, requests: Int, per_seconds: Int) -> String {
|
||||
let principal: String = ctx_get_meta(ctx, "user_id")
|
||||
if str_eq(principal, "") { let principal = ctx_get_meta(ctx, "ip") }
|
||||
let bucket_key: String = "rl:" + json_get(ctx, "target") + ":" + principal
|
||||
let allowed: Bool = rate_bucket_take(bucket_key, requests, per_seconds)
|
||||
if !allowed { return ctx_with_meta(ctx, "error", AOP_ERR_RATE_LIMITED) }
|
||||
ctx
|
||||
}
|
||||
|
||||
// ── @retry(attempts, backoff_ms) ────────────────────────────────────────────
|
||||
//
|
||||
// retry is necessarily an `around` aspect — it must own the loop.
|
||||
|
||||
fn aspect_retry_around(ctx: String, attempts: Int, backoff_ms: Int, proceed_fn_name: String) -> String {
|
||||
let i: Int = 0
|
||||
let result: String = ""
|
||||
while i < attempts {
|
||||
let result = call_dynamic(proceed_fn_name, ctx)
|
||||
let err: String = json_get(result, "error")
|
||||
if str_eq(err, "") { return result }
|
||||
sleep_ms(backoff_ms * (i + 1))
|
||||
let i = i + 1
|
||||
}
|
||||
ctx_with_meta(ctx, "error", AOP_ERR_RETRIES_EXHAUSTED)
|
||||
}
|
||||
|
||||
// ── @log / @trace ────────────────────────────────────────────────────────────
|
||||
|
||||
fn aspect_log_before(ctx: String) -> String {
|
||||
println("[aop] -> " + json_get(ctx, "target") + "." + json_get(ctx, "method"))
|
||||
ctx
|
||||
}
|
||||
|
||||
fn aspect_log_after(ctx: String, result: String) -> String {
|
||||
println("[aop] <- " + json_get(ctx, "target") + "." + json_get(ctx, "method"))
|
||||
result
|
||||
}
|
||||
|
||||
fn aspect_trace_before(ctx: String) -> String {
|
||||
let span_id: String = uuid_v4()
|
||||
ctx_with_meta(ctx, "span_id", span_id)
|
||||
}
|
||||
|
||||
// ── @validate(schema) ────────────────────────────────────────────────────────
|
||||
|
||||
fn aspect_validate_before(ctx: String, schema_json: String) -> String {
|
||||
let args: String = json_get(ctx, "args")
|
||||
let valid: Bool = json_schema_check(args, schema_json)
|
||||
if !valid { return ctx_with_meta(ctx, "error", AOP_ERR_VALIDATION) }
|
||||
ctx
|
||||
}
|
||||
|
||||
// ── Aspect chain composition ─────────────────────────────────────────────────
|
||||
//
|
||||
// The compiler emits a call sequence like:
|
||||
// ctx = ctx_new(...)
|
||||
// ctx = aspect_authenticate_before(ctx)
|
||||
// ctx = aspect_log_before(ctx)
|
||||
// result = proceed(ctx)
|
||||
// result = aspect_log_after(ctx, result)
|
||||
// At runtime an explicit `chain_run` exists for dynamic composition.
|
||||
|
||||
fn chain_run(ctx: String, before_fns: String, around_fn: String, after_fns: String, proceed_fn: String) -> String {
|
||||
let cur_ctx: String = ctx
|
||||
// before chain
|
||||
let i: Int = 0
|
||||
let befs: String = before_fns
|
||||
while !str_eq(befs, "") {
|
||||
let comma: Int = str_index_of(befs, ",")
|
||||
let fn_name: String = befs
|
||||
if comma > 0 { let fn_name = str_slice(befs, 0, comma) }
|
||||
let cur_ctx = call_dynamic(fn_name, cur_ctx)
|
||||
let err: String = ctx_get_meta(cur_ctx, "error")
|
||||
if !str_eq(err, "") { return cur_ctx }
|
||||
if comma > 0 { let befs = str_slice(befs, comma + 1, str_len(befs)) }
|
||||
if comma < 0 { let befs = "" }
|
||||
}
|
||||
// around / proceed
|
||||
let result: String = call_dynamic(proceed_fn, cur_ctx)
|
||||
// after chain (right-to-left composition; simplified left-to-right here)
|
||||
let afts: String = after_fns
|
||||
while !str_eq(afts, "") {
|
||||
let comma: Int = str_index_of(afts, ",")
|
||||
let fn_name: String = afts
|
||||
if comma > 0 { let fn_name = str_slice(afts, 0, comma) }
|
||||
let result = call_dynamic2(fn_name, cur_ctx, result)
|
||||
if comma > 0 { let afts = str_slice(afts, comma + 1, str_len(afts)) }
|
||||
if comma < 0 { let afts = "" }
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ───────────────────────────────────────────────────────
|
||||
|
||||
let ctx: String = ctx_new("ProfilePage", "render")
|
||||
let ctx = ctx_with_arg(ctx, "user_id", "u-001")
|
||||
println("[el-aop] ctx = " + ctx)
|
||||
@@ -0,0 +1,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/"
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// el-config — Layered, typed configuration for el-ui.
|
||||
//
|
||||
// Each layer is a key->value map. `Config::get(key)` walks layers from highest
|
||||
// to lowest priority and returns the first hit. Values are parsed into the
|
||||
// caller-requested type via `parse_<type>`.
|
||||
|
||||
// ── Environment ──────────────────────────────────────────────────────────────
|
||||
|
||||
let ENV_DEVELOPMENT: String = "development"
|
||||
let ENV_STAGING: String = "staging"
|
||||
let ENV_PRODUCTION: String = "production"
|
||||
let ENV_TEST: String = "test"
|
||||
|
||||
fn env_current() -> String {
|
||||
let raw: String = env("EL_ENV")
|
||||
if str_eq(raw, "") { return ENV_DEVELOPMENT }
|
||||
raw
|
||||
}
|
||||
|
||||
fn env_is_production(name: String) -> Bool {
|
||||
str_eq(name, "production")
|
||||
}
|
||||
|
||||
// ── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let CFG_ERR_MISSING: String = "config.missing"
|
||||
let CFG_ERR_PARSE: String = "config.parse_error"
|
||||
let CFG_ERR_TYPE: String = "config.type_error"
|
||||
|
||||
// ── Sources ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let SRC_ENV: String = "env"
|
||||
let SRC_DOTENV: String = "dotenv"
|
||||
let SRC_MANIFEST: String = "manifest"
|
||||
let SRC_DEFAULTS: String = "defaults"
|
||||
let SRC_MAP: String = "map"
|
||||
|
||||
type ConfigSource {
|
||||
kind: String
|
||||
priority: Int // higher = wins
|
||||
map_json: String // JSON: key -> string value
|
||||
}
|
||||
|
||||
fn source_env_vars(prefix: String) -> ConfigSource {
|
||||
// Snapshot env at construction (the runtime exposes env_keys()).
|
||||
let map: String = "{}"
|
||||
let keys: String = env_keys_with_prefix(prefix)
|
||||
let n: Int = json_array_len(keys)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let raw_key: String = json_array_get(keys, i)
|
||||
let value: String = env(raw_key)
|
||||
let logical_key: String = str_to_lower(str_replace(str_slice(raw_key, str_len(prefix), str_len(raw_key)), "_", "."))
|
||||
let map = json_set(map, logical_key, "\"" + value + "\"")
|
||||
let i = i + 1
|
||||
}
|
||||
{ "kind": "env", "priority": 100, "map_json": map }
|
||||
}
|
||||
|
||||
fn source_dotenv(path: String) -> ConfigSource {
|
||||
let raw: String = ""
|
||||
if fs_exists(path) { let raw = fs_read(path) }
|
||||
let map: String = dotenv_parse(raw)
|
||||
{ "kind": "dotenv", "priority": 80, "map_json": map }
|
||||
}
|
||||
|
||||
fn source_manifest(manifest_path: String, current_env: String) -> ConfigSource {
|
||||
let raw: String = fs_read(manifest_path)
|
||||
let base: String = manifest_section(raw, "config")
|
||||
let env_key: String = "env." + current_env
|
||||
let env_overrides: String = manifest_section(raw, env_key)
|
||||
let map: String = json_merge(base, env_overrides)
|
||||
{ "kind": "manifest", "priority": 60, "map_json": map }
|
||||
}
|
||||
|
||||
fn source_defaults(map_json: String) -> ConfigSource {
|
||||
{ "kind": "defaults", "priority": 0, "map_json": map_json }
|
||||
}
|
||||
|
||||
// ── Config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Config {
|
||||
environment: String // development | staging | production | test
|
||||
sources_json: String // JSON array of ConfigSource
|
||||
}
|
||||
|
||||
fn config_new(env_name: String) -> Config {
|
||||
{ "environment": env_name, "sources_json": "[]" }
|
||||
}
|
||||
|
||||
fn config_add_source(c: Config, src: ConfigSource) -> Config {
|
||||
let updated: String = json_array_push_sorted(c.sources_json, json_encode(src), "priority", true)
|
||||
{ "environment": c.environment, "sources_json": updated }
|
||||
}
|
||||
|
||||
fn config_set_defaults(c: Config, defaults_map: String) -> Config {
|
||||
config_add_source(c, source_defaults(defaults_map))
|
||||
}
|
||||
|
||||
fn config_get_string(c: Config, key: String) -> String {
|
||||
let n: Int = json_array_len(c.sources_json)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let src_json: String = json_array_get(c.sources_json, i)
|
||||
let map: String = json_get(src_json, "map_json")
|
||||
let v: String = json_get(map, key)
|
||||
if !str_eq(v, "") { return v }
|
||||
let i = i + 1
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
fn config_get_int(c: Config, key: String) -> Int {
|
||||
let raw: String = config_get_string(c, key)
|
||||
if str_eq(raw, "") { return 0 }
|
||||
str_to_int(raw)
|
||||
}
|
||||
|
||||
fn config_get_bool(c: Config, key: String) -> Bool {
|
||||
let raw: String = str_to_lower(config_get_string(c, key))
|
||||
if str_eq(raw, "true") { return true }
|
||||
if str_eq(raw, "1") { return true }
|
||||
if str_eq(raw, "yes") { return true }
|
||||
false
|
||||
}
|
||||
|
||||
// Strict variants — non-empty required.
|
||||
fn config_require_string(c: Config, key: String) -> String {
|
||||
let v: String = config_get_string(c, key)
|
||||
if str_eq(v, "") { panic(CFG_ERR_MISSING + ":" + key) }
|
||||
v
|
||||
}
|
||||
|
||||
// ── load_from_toml — convenience for TOML config files ──────────────────────
|
||||
|
||||
fn config_load_from_toml(path: String, env_name: String) -> Config {
|
||||
let cfg: Config = config_new(env_name)
|
||||
let raw: String = fs_read(path)
|
||||
let map: String = toml_to_json_flat(raw) // dotted keys
|
||||
let cfg = config_add_source(cfg, source_defaults(map))
|
||||
cfg
|
||||
}
|
||||
|
||||
// ── .env parser (minimal) ───────────────────────────────────────────────────
|
||||
|
||||
fn dotenv_parse(raw: String) -> String {
|
||||
let map: String = "{}"
|
||||
let lines: String = str_split(raw, "\n")
|
||||
let n: Int = json_array_len(lines)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let line: String = str_trim(json_array_get(lines, i))
|
||||
if str_eq(line, "") {
|
||||
let i = i + 1
|
||||
}
|
||||
if !str_eq(line, "") {
|
||||
if !str_starts_with(line, "#") {
|
||||
let eq: Int = str_index_of(line, "=")
|
||||
if eq > 0 {
|
||||
let k: String = str_trim(str_slice(line, 0, eq))
|
||||
let v: String = str_trim(str_slice(line, eq + 1, str_len(line)))
|
||||
let v = str_strip_quotes(v)
|
||||
let map = json_set(map, str_to_lower(str_replace(k, "_", ".")), "\"" + v + "\"")
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let cfg: Config = config_new(env_current())
|
||||
let defaults: String = "{\"app.name\":\"MyApp\",\"server.port\":\"8080\"}"
|
||||
let cfg = config_set_defaults(cfg, defaults)
|
||||
println("[el-config] env=" + cfg.environment + " app=" + config_get_string(cfg, "app.name"))
|
||||
@@ -0,0 +1,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/"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// canvas.el — Full server-side pipeline: layout -> render -> SVG string.
|
||||
//
|
||||
// This is the primary integration point for callers that want a static SVG
|
||||
// without managing the layout and render steps separately.
|
||||
//
|
||||
// Public API:
|
||||
// graph_svg_endpoint(nodes_json, edges_json, width, height) -> String
|
||||
// Full pipeline: Coulomb/spring layout (150 iterations) -> SVG string.
|
||||
// Returns a complete <svg>...</svg> string.
|
||||
//
|
||||
// Client-side interaction (drag, zoom, pan) is deferred until el-ui-compiler
|
||||
// gains a JavaScript backend. For now, all rendering is server-side.
|
||||
// Clients refresh the SVG on demand (e.g., polling GET /api/graph/svg).
|
||||
//
|
||||
// Zoom/pan note: SVG viewBox is fixed to [0,0,width,height]. When the JS
|
||||
// backend lands, el-ui-compiler will produce an overlay with pointer-event
|
||||
// handlers that transform a <g> wrapper inside this SVG. The server-side path
|
||||
// stays as a fallback for non-browser consumers (CLI, PDF export, testing).
|
||||
|
||||
fn layout_default_iterations() -> Int { 150 }
|
||||
|
||||
// ── graph_svg_endpoint ────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_svg_endpoint(nodes_json: String, edges_json: String, width: Int, height: Int) -> String {
|
||||
let w_f: Float = int_to_float(width)
|
||||
let h_f: Float = int_to_float(height)
|
||||
|
||||
// Step 1: compute layout
|
||||
let positions_json: String = layout_run(nodes_json, edges_json, w_f, h_f, layout_default_iterations())
|
||||
|
||||
// Step 2: render to SVG
|
||||
let svg: String = graph_render_svg(nodes_json, edges_json, positions_json, width, height)
|
||||
svg
|
||||
}
|
||||
|
||||
// ── graph_svg_endpoint_custom ─────────────────────────────────────────────────
|
||||
//
|
||||
// Same as above but with configurable iteration count.
|
||||
// Use when you need faster layout (low iters) or higher quality (high iters).
|
||||
|
||||
fn graph_svg_endpoint_custom(nodes_json: String, edges_json: String, width: Int, height: Int, iterations: Int) -> String {
|
||||
let w_f: Float = int_to_float(width)
|
||||
let h_f: Float = int_to_float(height)
|
||||
let positions_json: String = layout_run(nodes_json, edges_json, w_f, h_f, iterations)
|
||||
graph_render_svg(nodes_json, edges_json, positions_json, width, height)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// edge.el — Edge type definitions and visual encoding.
|
||||
//
|
||||
// Edges are directed (source -> target) with a weight and optional relation label.
|
||||
|
||||
// ── Edge JSON accessors ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Edges are passed as JSON objects: { source_id, target_id, weight, relation }
|
||||
|
||||
fn edge_source(e_json: String) -> String {
|
||||
let s: String = json_get_string(e_json, "source_id")
|
||||
if !str_eq(s, "") { return s }
|
||||
json_get_string(e_json, "source")
|
||||
}
|
||||
|
||||
fn edge_target(e_json: String) -> String {
|
||||
let t: String = json_get_string(e_json, "target_id")
|
||||
if !str_eq(t, "") { return t }
|
||||
json_get_string(e_json, "target")
|
||||
}
|
||||
|
||||
fn edge_weight(e_json: String) -> Float {
|
||||
let w: Float = json_get_float(e_json, "weight")
|
||||
if w == int_to_float(0) { return int_to_float(1) }
|
||||
w
|
||||
}
|
||||
|
||||
fn edge_relation(e_json: String) -> String {
|
||||
json_get_string(e_json, "relation")
|
||||
}
|
||||
|
||||
// ── Edge visual encoding ─────────────────────────────────────────────────────
|
||||
|
||||
// Stroke width clamped to [1, 4] based on weight.
|
||||
fn edge_stroke_width(weight: Float) -> Float {
|
||||
let min_w: Float = int_to_float(1)
|
||||
let max_w: Float = int_to_float(4)
|
||||
let range: Float = max_w - min_w
|
||||
let clamped: Float = if weight < min_w { min_w } else { if weight > max_w { max_w } else { weight } }
|
||||
clamped
|
||||
}
|
||||
|
||||
fn edge_stroke_color() -> String { "#3a4a5a" }
|
||||
|
||||
fn edge_stroke_color_highlight() -> String { "#5a7a9a" }
|
||||
@@ -0,0 +1,138 @@
|
||||
// editor.el — Round-trip graph editing API.
|
||||
//
|
||||
// Provides El functions for mutating the Engram graph (the live knowledge graph
|
||||
// stored in-process via engram_* builtins). These functions are the mutation
|
||||
// layer for graph editors — the CGI Studio Engram panel will call these to add,
|
||||
// remove, and connect nodes without reloading the whole graph.
|
||||
//
|
||||
// All mutations write directly to the in-process Engram via engram_* builtins
|
||||
// (see BOOTSTRAP.md §Engram Knowledge Graph).
|
||||
//
|
||||
// Drag interaction is NOT implemented here — that requires pointer-event
|
||||
// handlers in JavaScript. When el-ui-compiler gains a JS backend, wire the
|
||||
// move_node() position cache to the layout state keys used in layout.el.
|
||||
//
|
||||
// Public API:
|
||||
// graph_add_node(content, node_type, label, salience) -> String // node_id or ""
|
||||
// graph_remove_node(node_id) -> String // "ok" or error JSON
|
||||
// graph_add_edge(from_id, to_id, weight_str, relation) -> String // "ok" or error JSON
|
||||
// graph_remove_edge(from_id, to_id) -> String // "ok" or error JSON
|
||||
// graph_move_node(node_id, x_str, y_str) -> String // "ok" (position cache)
|
||||
|
||||
// ── graph_add_node ────────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_add_node(content: String, node_type: String, label: String, salience_str: String) -> String {
|
||||
if str_eq(content, "") {
|
||||
return "{\"error\":\"content is required\"}"
|
||||
}
|
||||
let sal: Float = if str_eq(salience_str, "") { int_to_float(1) } else { str_to_float(salience_str) }
|
||||
// engram_node_full: content, type, label, salience, importance, confidence, tier, tags
|
||||
let eff_label: String = if str_eq(label, "") { str_slice(content, 0, 40) } else { label }
|
||||
let eff_type: String = if str_eq(node_type, "") { "Entity" } else { node_type }
|
||||
let node_id: String = engram_node_full(content, eff_type, eff_label, sal, sal, int_to_float(1), "Working", "")
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"engram_node_full returned empty id\"}"
|
||||
}
|
||||
"{\"id\":\"" + node_id + "\"}"
|
||||
}
|
||||
|
||||
// ── graph_remove_node ─────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_remove_node(node_id: String) -> String {
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"node_id is required\"}"
|
||||
}
|
||||
// Check node exists
|
||||
let existing: String = engram_get_node(node_id)
|
||||
if str_eq(existing, "") {
|
||||
return "{\"error\":\"node not found\",\"id\":\"" + node_id + "\"}"
|
||||
}
|
||||
engram_forget(node_id)
|
||||
"{\"ok\":true,\"id\":\"" + node_id + "\"}"
|
||||
}
|
||||
|
||||
// ── graph_add_edge ────────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_add_edge(from_id: String, to_id: String, weight_str: String, relation: String) -> String {
|
||||
if str_eq(from_id, "") {
|
||||
return "{\"error\":\"from_id is required\"}"
|
||||
}
|
||||
if str_eq(to_id, "") {
|
||||
return "{\"error\":\"to_id is required\"}"
|
||||
}
|
||||
let w: Float = if str_eq(weight_str, "") { int_to_float(1) } else { str_to_float(weight_str) }
|
||||
let rel: String = if str_eq(relation, "") { "relates_to" } else { relation }
|
||||
// engram_connect(from, to, weight, relation)
|
||||
let edge_id: String = engram_connect(from_id, to_id, w, rel)
|
||||
if str_eq(edge_id, "") {
|
||||
return "{\"error\":\"engram_connect failed\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
|
||||
}
|
||||
"{\"ok\":true,\"edge_id\":\"" + edge_id + "\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
|
||||
}
|
||||
|
||||
// ── graph_remove_edge ─────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_remove_edge(from_id: String, to_id: String) -> String {
|
||||
if str_eq(from_id, "") {
|
||||
return "{\"error\":\"from_id is required\"}"
|
||||
}
|
||||
if str_eq(to_id, "") {
|
||||
return "{\"error\":\"to_id is required\"}"
|
||||
}
|
||||
// Check edge exists
|
||||
let existing: String = engram_edge_between(from_id, to_id)
|
||||
if str_eq(existing, "") {
|
||||
return "{\"error\":\"edge not found\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
|
||||
}
|
||||
// No engram_remove_edge builtin — use engram_forget on the edge node if
|
||||
// an edge ID was returned, otherwise surface a not-implemented note.
|
||||
// In practice, engram_forget(node_id) removes a node and its edges;
|
||||
// there is no "remove edge only" primitive yet.
|
||||
"{\"error\":\"remove_edge not yet supported by engram builtins — remove the node to remove all its edges\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
|
||||
}
|
||||
|
||||
// ── graph_move_node ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Caches a node's screen position for use by the layout engine.
|
||||
// When el-ui-compiler ships JS output, drag handlers will call this after
|
||||
// pointer-up to persist the dragged position so the next render uses it as
|
||||
// the initial position (preventing snap-back after re-layout).
|
||||
|
||||
fn graph_move_node(node_id: String, x_str: String, y_str: String) -> String {
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"node_id is required\"}"
|
||||
}
|
||||
// Store in process state — layout.el reads these keys as initial positions.
|
||||
state_set("node_x_" + node_id, x_str)
|
||||
state_set("node_y_" + node_id, y_str)
|
||||
// Also zero the velocity so the node doesn't immediately drift.
|
||||
state_set("node_vx_" + node_id, "0.0")
|
||||
state_set("node_vy_" + node_id, "0.0")
|
||||
"{\"ok\":true,\"id\":\"" + node_id + "\",\"x\":" + x_str + ",\"y\":" + y_str + "}"
|
||||
}
|
||||
|
||||
// ── graph_node_info ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Retrieve full node data from Engram (for inspector panels).
|
||||
|
||||
fn graph_node_info(node_id: String) -> String {
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"node_id is required\"}"
|
||||
}
|
||||
let n: String = engram_get_node(node_id)
|
||||
if str_eq(n, "") {
|
||||
return "{\"error\":\"node not found\",\"id\":\"" + node_id + "\"}"
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
// ── graph_neighbors ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Return the neighbors of a node as a JSON array (for sub-graph drill-down).
|
||||
|
||||
fn graph_neighbors_json(node_id: String) -> String {
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"node_id is required\"}"
|
||||
}
|
||||
engram_neighbors(node_id)
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// layout.el — Force-directed layout engine (pure El math).
|
||||
//
|
||||
// Implements a basic spring-force simulation:
|
||||
// - Coulomb repulsion between every pair of nodes (O(n²))
|
||||
// - Hooke spring attraction along edges
|
||||
// - Weak gravity toward the canvas center
|
||||
// - Velocity damping per iteration
|
||||
//
|
||||
// Float representation: El stores floats as bit-cast int64_t values.
|
||||
// All math uses int_to_float() for literals and math_sqrt() for sqrt.
|
||||
//
|
||||
// Public API:
|
||||
// layout_run(nodes_json, edges_json, width, height, iterations) -> String
|
||||
// nodes_json — JSON array: [{ id, salience, ... }, ...]
|
||||
// edges_json — JSON array: [{ source_id, target_id, weight }, ...]
|
||||
// width/height — canvas Float dimensions
|
||||
// iterations — simulation steps (default 150 for good convergence)
|
||||
// Returns JSON array: [{ id, x, y }, ...]
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn layout_repulsion_k() -> Float {
|
||||
// Coulomb constant — controls node spread.
|
||||
int_to_float(3000)
|
||||
}
|
||||
|
||||
fn layout_spring_k() -> Float {
|
||||
// Spring stiffness for edge attraction.
|
||||
int_to_float(1)
|
||||
}
|
||||
|
||||
fn layout_spring_rest() -> Float {
|
||||
// Rest length for edges (px).
|
||||
int_to_float(80)
|
||||
}
|
||||
|
||||
fn layout_gravity_k() -> Float {
|
||||
// Gravity toward center (gentle).
|
||||
int_to_float(1)
|
||||
}
|
||||
|
||||
fn layout_damping() -> Float {
|
||||
// Velocity decay per step (0.85 = 15% loss per step).
|
||||
let d: Float = int_to_float(85)
|
||||
d / int_to_float(100)
|
||||
}
|
||||
|
||||
fn layout_max_velocity() -> Float {
|
||||
// Cap velocity per step to avoid explosion.
|
||||
int_to_float(50)
|
||||
}
|
||||
|
||||
fn layout_min_dist() -> Float {
|
||||
// Minimum distance to prevent division by zero in repulsion.
|
||||
int_to_float(1)
|
||||
}
|
||||
|
||||
// ── State keys (process state for per-node data) ─────────────────────────────
|
||||
//
|
||||
// We use process state (state_set/state_get) as a flat key/value store since
|
||||
// El does not have mutable arrays or map mutation without re-assignment.
|
||||
// Key patterns:
|
||||
// "node_ids" — comma-separated node id list
|
||||
// "node_x_<id>" — x position
|
||||
// "node_y_<id>" — y position
|
||||
// "node_vx_<id>" — x velocity
|
||||
// "node_vy_<id>" — y velocity
|
||||
|
||||
fn layout_key_x(node_id: String) -> String { "node_x_" + node_id }
|
||||
fn layout_key_y(node_id: String) -> String { "node_y_" + node_id }
|
||||
fn layout_key_vx(node_id: String) -> String { "node_vx_" + node_id }
|
||||
fn layout_key_vy(node_id: String) -> String { "node_vy_" + node_id }
|
||||
|
||||
// ── Initialization ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Distribute nodes in a circle around the center so no two start at the
|
||||
// same position (which would make repulsion forces zero and give no movement).
|
||||
|
||||
fn layout_init_positions(node_ids: String, cx: Float, cy: Float) -> Bool {
|
||||
let ids: [String] = str_split(node_ids, ",")
|
||||
let count: Int = el_list_len(ids)
|
||||
if count == 0 { return true }
|
||||
let pi2: Float = math_pi() * int_to_float(2)
|
||||
let radius: Float = int_to_float(100) + int_to_float(20) * int_to_float(count)
|
||||
let i: Int = 0
|
||||
while i < count {
|
||||
let id: String = el_list_get(ids, i)
|
||||
let angle: Float = pi2 * int_to_float(i) / int_to_float(count)
|
||||
let x: Float = cx + radius * math_cos(angle)
|
||||
let y: Float = cy + math_sin(angle) * radius
|
||||
state_set(layout_key_x(id), float_to_str(x))
|
||||
state_set(layout_key_y(id), float_to_str(x))
|
||||
state_set(layout_key_y(id), float_to_str(y))
|
||||
state_set(layout_key_vx(id), "0.0")
|
||||
state_set(layout_key_vy(id), "0.0")
|
||||
let i = i + 1
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ── Float helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn layout_get_x(id: String) -> Float {
|
||||
str_to_float(state_get(layout_key_x(id)))
|
||||
}
|
||||
|
||||
fn layout_get_y(id: String) -> Float {
|
||||
str_to_float(state_get(layout_key_y(id)))
|
||||
}
|
||||
|
||||
fn layout_get_vx(id: String) -> Float {
|
||||
str_to_float(state_get(layout_key_vx(id)))
|
||||
}
|
||||
|
||||
fn layout_get_vy(id: String) -> Float {
|
||||
str_to_float(state_get(layout_key_vy(id)))
|
||||
}
|
||||
|
||||
fn float_clamp(v: Float, lo: Float, hi: Float) -> Float {
|
||||
if v < lo { return lo }
|
||||
if v > hi { return hi }
|
||||
v
|
||||
}
|
||||
|
||||
fn float_abs(v: Float) -> Float {
|
||||
if v < int_to_float(0) { return int_to_float(0) - v }
|
||||
v
|
||||
}
|
||||
|
||||
// ── Repulsion pass ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// For each pair (a, b): compute Coulomb repulsion and accumulate forces.
|
||||
// Force direction: along the vector from b to a (a is pushed away from b).
|
||||
// Magnitude: k / dist^2
|
||||
|
||||
fn layout_repulsion_pass(node_ids: String) -> Bool {
|
||||
let ids: [String] = str_split(node_ids, ",")
|
||||
let n: Int = el_list_len(ids)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let id_a: String = el_list_get(ids, i)
|
||||
let ax: Float = layout_get_x(id_a)
|
||||
let ay: Float = layout_get_y(id_a)
|
||||
let fx: Float = int_to_float(0)
|
||||
let fy: Float = int_to_float(0)
|
||||
let j: Int = 0
|
||||
while j < n {
|
||||
if j != i {
|
||||
let id_b: String = el_list_get(ids, j)
|
||||
let bx: Float = layout_get_x(id_b)
|
||||
let by: Float = layout_get_y(id_b)
|
||||
let dx: Float = ax - bx
|
||||
let dy: Float = ay - by
|
||||
let dist_sq: Float = dx * dx + dy * dy
|
||||
let dist: Float = math_sqrt(dist_sq)
|
||||
let safe_dist: Float = if dist < layout_min_dist() { layout_min_dist() } else { dist }
|
||||
let force: Float = layout_repulsion_k() / (safe_dist * safe_dist)
|
||||
let nx: Float = dx / safe_dist
|
||||
let ny: Float = dy / safe_dist
|
||||
let fx = fx + nx * force
|
||||
let fy = fy + ny * force
|
||||
}
|
||||
let j = j + 1
|
||||
}
|
||||
// Accumulate: store forces temporarily in velocity (they are scaled later)
|
||||
// Use "fx_<id>" keys for accumulation.
|
||||
state_set("fx_" + id_a, float_to_str(fx))
|
||||
state_set("fy_" + id_a, float_to_str(fy))
|
||||
let i = i + 1
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ── Spring pass ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// For each edge (a->b): apply Hooke spring toward rest length.
|
||||
// Both endpoints feel the force (attractive when dist > rest, repulsive when < rest).
|
||||
|
||||
fn layout_spring_pass(node_ids: String, edges_json: String) -> Bool {
|
||||
let edge_count: Int = json_array_len(edges_json)
|
||||
let i: Int = 0
|
||||
while i < edge_count {
|
||||
let e: String = json_array_get(edges_json, i)
|
||||
let src: String = json_get_string(e, "source_id")
|
||||
let tgt_raw: String = json_get_string(e, "target_id")
|
||||
// Support both source_id/target_id and source/target field names
|
||||
let src2: String = if str_eq(src, "") { json_get_string(e, "source") } else { src }
|
||||
let tgt2: String = if str_eq(tgt_raw, "") { json_get_string(e, "target") } else { tgt_raw }
|
||||
let w: Float = json_get_float(e, "weight")
|
||||
let eff_w: Float = if w == int_to_float(0) { int_to_float(1) } else { w }
|
||||
|
||||
// Only apply spring if both endpoints are in our node set
|
||||
let sx: String = state_get(layout_key_x(src2))
|
||||
let tx_chk: String = state_get(layout_key_x(tgt2))
|
||||
if !str_eq(sx, "") {
|
||||
if !str_eq(tx_chk, "") {
|
||||
let ax: Float = layout_get_x(src2)
|
||||
let ay: Float = layout_get_y(src2)
|
||||
let bx: Float = layout_get_x(tgt2)
|
||||
let by_val: Float = layout_get_y(tgt2)
|
||||
let dx: Float = bx - ax
|
||||
let dy: Float = by_val - ay
|
||||
let dist_sq: Float = dx * dx + dy * dy
|
||||
let dist: Float = math_sqrt(dist_sq)
|
||||
let safe_dist: Float = if dist < layout_min_dist() { layout_min_dist() } else { dist }
|
||||
let stretch: Float = (safe_dist - layout_spring_rest()) * layout_spring_k() * eff_w
|
||||
let nx: Float = dx / safe_dist
|
||||
let ny: Float = dy / safe_dist
|
||||
let spring_fx: Float = nx * stretch
|
||||
let spring_fy: Float = ny * stretch
|
||||
|
||||
// Add to accumulated forces
|
||||
let cur_fx_a: Float = str_to_float(state_get("fx_" + src2))
|
||||
let cur_fy_a: Float = str_to_float(state_get("fy_" + src2))
|
||||
state_set("fx_" + src2, float_to_str(cur_fx_a + spring_fx))
|
||||
state_set("fy_" + src2, float_to_str(cur_fy_a + spring_fy))
|
||||
|
||||
let cur_fx_b: Float = str_to_float(state_get("fx_" + tgt2))
|
||||
let cur_fy_b: Float = str_to_float(state_get("fy_" + tgt2))
|
||||
state_set("fx_" + tgt2, float_to_str(cur_fx_b - spring_fx))
|
||||
state_set("fy_" + tgt2, float_to_str(cur_fy_b - spring_fy))
|
||||
}
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ── Gravity pass ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Weak attraction toward canvas center to prevent isolated nodes from drifting.
|
||||
|
||||
fn layout_gravity_pass(node_ids: String, cx: Float, cy: Float) -> Bool {
|
||||
let ids: [String] = str_split(node_ids, ",")
|
||||
let n: Int = el_list_len(ids)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let id: String = el_list_get(ids, i)
|
||||
let x: Float = layout_get_x(id)
|
||||
let y: Float = layout_get_y(id)
|
||||
let gx: Float = (cx - x) * layout_gravity_k() / int_to_float(100)
|
||||
let gy: Float = (cy - y) * layout_gravity_k() / int_to_float(100)
|
||||
let cur_fx: Float = str_to_float(state_get("fx_" + id))
|
||||
let cur_fy: Float = str_to_float(state_get("fy_" + id))
|
||||
state_set("fx_" + id, float_to_str(cur_fx + gx))
|
||||
state_set("fy_" + id, float_to_str(cur_fy + gy))
|
||||
let i = i + 1
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ── Integration pass ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Apply forces to velocities (with damping), then update positions.
|
||||
// Clamp positions to stay within canvas bounds (with 20px margin).
|
||||
|
||||
fn layout_integrate(node_ids: String, width: Float, height: Float) -> Bool {
|
||||
let ids: [String] = str_split(node_ids, ",")
|
||||
let n: Int = el_list_len(ids)
|
||||
let max_v: Float = layout_max_velocity()
|
||||
let damp: Float = layout_damping()
|
||||
let margin: Float = int_to_float(20)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let id: String = el_list_get(ids, i)
|
||||
let vx: Float = (layout_get_vx(id) + str_to_float(state_get("fx_" + id))) * damp
|
||||
let vy: Float = (layout_get_vy(id) + str_to_float(state_get("fy_" + id))) * damp
|
||||
// Clamp velocity magnitude
|
||||
let vx_clamped: Float = float_clamp(vx, int_to_float(0) - max_v, max_v)
|
||||
let vy_clamped: Float = float_clamp(vy, int_to_float(0) - max_v, max_v)
|
||||
let new_x: Float = float_clamp(layout_get_x(id) + vx_clamped, margin, width - margin)
|
||||
let new_y: Float = float_clamp(layout_get_y(id) + vy_clamped, margin, height - margin)
|
||||
state_set(layout_key_x(id), float_to_str(new_x))
|
||||
state_set(layout_key_y(id), float_to_str(new_y))
|
||||
state_set(layout_key_vx(id), float_to_str(vx_clamped))
|
||||
state_set(layout_key_vy(id), float_to_str(vy_clamped))
|
||||
// Reset force accumulators for next iteration
|
||||
state_set("fx_" + id, "0.0")
|
||||
state_set("fy_" + id, "0.0")
|
||||
let i = i + 1
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ── Public: layout_run ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Full pipeline: init positions, run N iterations, return positions as JSON.
|
||||
//
|
||||
// Input nodes_json must be a JSON array of objects with at least an "id" field.
|
||||
// Returns: JSON array [{ "id": "...", "x": 123.0, "y": 456.0 }, ...]
|
||||
|
||||
fn layout_run(nodes_json: String, edges_json: String, width: Float, height: Float, iterations: Int) -> String {
|
||||
let cx: Float = width / int_to_float(2)
|
||||
let cy: Float = height / int_to_float(2)
|
||||
|
||||
// Build comma-separated node_ids list
|
||||
let node_count: Int = json_array_len(nodes_json)
|
||||
if node_count == 0 { return "[]" }
|
||||
|
||||
let node_ids: String = ""
|
||||
let first: Bool = true
|
||||
let i: Int = 0
|
||||
while i < node_count {
|
||||
let n: String = json_array_get(nodes_json, i)
|
||||
let id: String = json_get_string(n, "id")
|
||||
if !str_eq(id, "") {
|
||||
if first {
|
||||
let node_ids = id
|
||||
let first = false
|
||||
} else {
|
||||
let node_ids = node_ids + "," + id
|
||||
}
|
||||
// Pre-initialize force accumulators
|
||||
state_set("fx_" + id, "0.0")
|
||||
state_set("fy_" + id, "0.0")
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
|
||||
// Initialize positions (circle around center)
|
||||
layout_init_positions(node_ids, cx, cy)
|
||||
|
||||
// Simulation loop
|
||||
let iter: Int = 0
|
||||
while iter < iterations {
|
||||
layout_repulsion_pass(node_ids)
|
||||
layout_spring_pass(node_ids, edges_json)
|
||||
layout_gravity_pass(node_ids, cx, cy)
|
||||
layout_integrate(node_ids, width, height)
|
||||
let iter = iter + 1
|
||||
}
|
||||
|
||||
// Collect results as JSON array
|
||||
let result: String = "["
|
||||
let ids: [String] = str_split(node_ids, ",")
|
||||
let n2: Int = el_list_len(ids)
|
||||
let j: Int = 0
|
||||
while j < n2 {
|
||||
let id: String = el_list_get(ids, j)
|
||||
let x: Float = layout_get_x(id)
|
||||
let y: Float = layout_get_y(id)
|
||||
let entry: String = "{\"id\":\"" + id + "\",\"x\":" + format_float(x, 1) + ",\"y\":" + format_float(y, 1) + "}"
|
||||
if j == 0 {
|
||||
let result = result + entry
|
||||
} else {
|
||||
let result = result + "," + entry
|
||||
}
|
||||
let j = j + 1
|
||||
}
|
||||
let result = result + "]"
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// main.el — el-graph vessel entry point.
|
||||
//
|
||||
// Re-exports all public functions from the sub-modules. The vessel is
|
||||
// compiled as a single translation unit (all imports are concatenated by
|
||||
// the build harness before elc runs). This file is the canonical import
|
||||
// target for downstream consumers.
|
||||
//
|
||||
// Import order matters only for readability — elc emits forward declarations
|
||||
// for all top-level functions so any order compiles correctly.
|
||||
|
||||
import "node.el"
|
||||
import "edge.el"
|
||||
import "layout.el"
|
||||
import "view.el"
|
||||
import "canvas.el"
|
||||
import "editor.el"
|
||||
import "serializer.el"
|
||||
|
||||
// ── Smoke test ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Verifies the vessel initializes correctly. Runs a minimal 2-node layout
|
||||
// and checks that the output is a non-empty JSON array.
|
||||
//
|
||||
// This runs at module load time (top-level El statements execute sequentially).
|
||||
// Remove or gate behind an env flag if startup overhead matters.
|
||||
|
||||
println("[el-graph] v0.1.0 — force layout + SVG renderer")
|
||||
println("[el-graph] node_color(Memory) = " + node_color("Memory"))
|
||||
println("[el-graph] node_radius(0.8) = " + int_to_str(node_radius_int(int_to_float(8) / int_to_float(10))))
|
||||
|
||||
let _smoke_nodes: String = "[{\"id\":\"a\",\"salience\":0.8,\"node_type\":\"Memory\"},{\"id\":\"b\",\"salience\":0.5,\"node_type\":\"Entity\"}]"
|
||||
let _smoke_edges: String = "[{\"source_id\":\"a\",\"target_id\":\"b\",\"weight\":1.0}]"
|
||||
let _smoke_pos: String = layout_run(_smoke_nodes, _smoke_edges, int_to_float(400), int_to_float(300), 10)
|
||||
println("[el-graph] smoke layout (10 iter) = " + str_slice(_smoke_pos, 0, 60) + "...")
|
||||
@@ -0,0 +1,77 @@
|
||||
// node.el — Node type definitions and color/radius mapping.
|
||||
//
|
||||
// Node types mirror the Engram knowledge graph node_type field.
|
||||
// Colors are chosen for dark-background (Studio) legibility.
|
||||
|
||||
// ── Node type constants ──────────────────────────────────────────────────────
|
||||
|
||||
fn node_type_memory() -> String { "Memory" }
|
||||
fn node_type_backlog() -> String { "BacklogItem" }
|
||||
fn node_type_knowledge() -> String { "Knowledge" }
|
||||
fn node_type_entity() -> String { "Entity" }
|
||||
fn node_type_default() -> String { "Node" }
|
||||
|
||||
// ── Color map ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn node_color(node_type: String) -> String {
|
||||
if str_eq(node_type, "Memory") { return "#58A6FF" }
|
||||
if str_eq(node_type, "BacklogItem") { return "#C9A84C" }
|
||||
if str_eq(node_type, "Knowledge") { return "#2ecc71" }
|
||||
if str_eq(node_type, "Entity") { return "#e74c3c" }
|
||||
if str_eq(node_type, "WorkContext") { return "#9b59b6" }
|
||||
if str_eq(node_type, "Artifact") { return "#1abc9c" }
|
||||
if str_eq(node_type, "Process") { return "#e67e22" }
|
||||
"#7a8ba8"
|
||||
}
|
||||
|
||||
// ── Radius ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Clamp salience (0.0–1.0) to radius range [6, 18].
|
||||
|
||||
fn node_radius(salience: Float) -> Float {
|
||||
let min_r: Float = int_to_float(6)
|
||||
let max_r: Float = int_to_float(18)
|
||||
let range: Float = max_r - min_r
|
||||
let clamped: Float = if salience < int_to_float(0) { int_to_float(0) } else { if salience > int_to_float(1) { int_to_float(1) } else { salience } }
|
||||
min_r + range * clamped
|
||||
}
|
||||
|
||||
fn node_radius_int(salience: Float) -> Int {
|
||||
float_to_int(node_radius(salience))
|
||||
}
|
||||
|
||||
// ── Label truncation ─────────────────────────────────────────────────────────
|
||||
|
||||
fn node_label_truncate(label: String) -> String {
|
||||
let max_len: Int = 30
|
||||
let l: Int = str_len(label)
|
||||
if l <= max_len { return label }
|
||||
str_slice(label, 0, max_len) + "..."
|
||||
}
|
||||
|
||||
// ── Node JSON accessors ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Nodes are passed as JSON objects: { id, label, node_type, salience, ... }
|
||||
|
||||
fn node_id(n_json: String) -> String {
|
||||
json_get_string(n_json, "id")
|
||||
}
|
||||
|
||||
fn node_label(n_json: String) -> String {
|
||||
let lbl: String = json_get_string(n_json, "label")
|
||||
if !str_eq(lbl, "") { return lbl }
|
||||
// Fall back to first 40 chars of content
|
||||
let c: String = json_get_string(n_json, "content")
|
||||
if str_len(c) > 40 { return str_slice(c, 0, 40) }
|
||||
c
|
||||
}
|
||||
|
||||
fn node_type_field(n_json: String) -> String {
|
||||
let t: String = json_get_string(n_json, "node_type")
|
||||
if str_eq(t, "") { return node_type_default() }
|
||||
t
|
||||
}
|
||||
|
||||
fn node_salience(n_json: String) -> Float {
|
||||
json_get_float(n_json, "salience")
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// serializer.el — Export graph as SVG string or portable JSON.
|
||||
//
|
||||
// Public API:
|
||||
// graph_to_svg(graph_json, width, height) -> String
|
||||
// graph_json: { "nodes": [...], "edges": [...] }
|
||||
// Full pipeline: parse -> layout -> render -> SVG string.
|
||||
//
|
||||
// graph_to_json(nodes_json, edges_json, positions_json) -> String
|
||||
// Portable export combining node data with computed positions.
|
||||
// Useful for saving layouts to disk or sending to other tools.
|
||||
|
||||
// ── graph_to_svg ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Convenience wrapper: accepts a combined graph JSON object and returns SVG.
|
||||
|
||||
fn graph_to_svg(graph_json: String, width: Int, height: Int) -> String {
|
||||
let nodes_raw: String = json_get_raw(graph_json, "nodes")
|
||||
let edges_raw: String = json_get_raw(graph_json, "edges")
|
||||
let nodes_json: String = if str_eq(nodes_raw, "") { "[]" } else { nodes_raw }
|
||||
let edges_json: String = if str_eq(edges_raw, "") { "[]" } else { edges_raw }
|
||||
graph_svg_endpoint(nodes_json, edges_json, width, height)
|
||||
}
|
||||
|
||||
// ── graph_to_json ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Merge node metadata with computed positions into a portable export format.
|
||||
// Output: { "nodes": [{...node fields..., "x": 123.0, "y": 456.0}], "edges": [...] }
|
||||
|
||||
fn graph_to_json(nodes_json: String, edges_json: String, positions_json: String) -> String {
|
||||
// Index positions by id
|
||||
build_position_index(positions_json)
|
||||
|
||||
let node_count: Int = json_array_len(nodes_json)
|
||||
let nodes_out: String = "["
|
||||
let i: Int = 0
|
||||
while i < node_count {
|
||||
let n: String = json_array_get(nodes_json, i)
|
||||
let id: String = json_get_string(n, "id")
|
||||
let x: Float = get_pos_x(id)
|
||||
let y: Float = get_pos_y(id)
|
||||
// Inject x/y into the node JSON
|
||||
let n_with_pos: String = json_set(json_set(n, "x", format_float(x, 1)), "y", format_float(y, 1))
|
||||
if i == 0 {
|
||||
let nodes_out = nodes_out + n_with_pos
|
||||
} else {
|
||||
let nodes_out = nodes_out + "," + n_with_pos
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
let nodes_out = nodes_out + "]"
|
||||
|
||||
"{\"nodes\":" + nodes_out + ",\"edges\":" + edges_json + "}"
|
||||
}
|
||||
|
||||
// ── graph_snapshot_svg ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Render a snapshot of the current in-process Engram graph as SVG.
|
||||
// Uses engram_scan_nodes_json and reads edges from the snapshot file.
|
||||
// This is the function called by the CGI Studio /api/graph/svg endpoint.
|
||||
|
||||
fn graph_snapshot_svg(width: Int, height: Int, snap_path: String) -> String {
|
||||
let nodes_json: String = engram_scan_nodes_json(9999, 0)
|
||||
let n_count: Int = json_array_len(nodes_json)
|
||||
|
||||
// Read edges from snapshot file
|
||||
let snap: String = fs_read(snap_path)
|
||||
let edges_raw: String = if str_eq(snap, "") { "[]" } else { json_get_raw(snap, "edges") }
|
||||
let edges_json: String = if str_eq(edges_raw, "") { "[]" } else { edges_raw }
|
||||
|
||||
if n_count == 0 {
|
||||
// Return an empty SVG with a "no data" message
|
||||
return svg_open(width, height) +
|
||||
"<text x=\"" + int_to_str(width / 2) + "\" y=\"" + int_to_str(height / 2) + "\" " +
|
||||
"text-anchor=\"middle\" fill=\"#8b9aaa\" font-size=\"14\" " +
|
||||
"font-family=\"IBM Plex Mono,monospace\">No nodes in graph</text>" +
|
||||
svg_close()
|
||||
}
|
||||
|
||||
graph_svg_endpoint(nodes_json, edges_json, width, height)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// view.el — SVG renderer for the force-directed graph.
|
||||
//
|
||||
// Takes layout positions + node/edge data and produces a complete SVG string.
|
||||
// Rendering is purely server-side — no DOM, no JavaScript.
|
||||
//
|
||||
// Public API:
|
||||
// graph_render_svg(nodes_json, edges_json, positions_json, width, height) -> String
|
||||
// Returns a complete <svg>...</svg> string ready for embedding or serving.
|
||||
//
|
||||
// Visual conventions:
|
||||
// - Background: #0d1117 (dark, matching Studio theme)
|
||||
// - Edges drawn first (below nodes)
|
||||
// - Nodes: filled circle with stroke, radius by salience
|
||||
// - Labels: truncated to 30 chars, below node, 10px IBM Plex Mono
|
||||
|
||||
// ── SVG helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn svg_open(width: Int, height: Int) -> String {
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" " +
|
||||
"width=\"" + int_to_str(width) + "\" " +
|
||||
"height=\"" + int_to_str(height) + "\" " +
|
||||
"viewBox=\"0 0 " + int_to_str(width) + " " + int_to_str(height) + "\" " +
|
||||
"style=\"background:#0d1117;font-family:'IBM Plex Mono',monospace\">"
|
||||
}
|
||||
|
||||
fn svg_close() -> String { "</svg>" }
|
||||
|
||||
fn svg_defs() -> String {
|
||||
"<defs>" +
|
||||
"<filter id=\"glow\"><feGaussianBlur stdDeviation=\"2\" result=\"blur\"/>" +
|
||||
"<feMerge><feMergeNode in=\"blur\"/><feMergeNode in=\"SourceGraphic\"/></feMerge></filter>" +
|
||||
"</defs>"
|
||||
}
|
||||
|
||||
// ── Edge rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
fn svg_edge(x1: Float, y1: Float, x2: Float, y2: Float, weight: Float) -> String {
|
||||
let sw: Float = edge_stroke_width(weight)
|
||||
let sw_str: String = format_float(sw, 1)
|
||||
let x1s: String = format_float(x1, 1)
|
||||
let y1s: String = format_float(y1, 1)
|
||||
let x2s: String = format_float(x2, 1)
|
||||
let y2s: String = format_float(y2, 1)
|
||||
"<line " +
|
||||
"x1=\"" + x1s + "\" y1=\"" + y1s + "\" " +
|
||||
"x2=\"" + x2s + "\" y2=\"" + y2s + "\" " +
|
||||
"stroke=\"" + edge_stroke_color() + "\" " +
|
||||
"stroke-width=\"" + sw_str + "\" " +
|
||||
"stroke-opacity=\"0.7\"/>"
|
||||
}
|
||||
|
||||
// ── Node rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
fn svg_node(x: Float, y: Float, radius: Int, color: String, label: String) -> String {
|
||||
let xs: String = format_float(x, 1)
|
||||
let ys: String = format_float(y, 1)
|
||||
let rs: String = int_to_str(radius)
|
||||
let label_trunc: String = node_label_truncate(label)
|
||||
// Escape XML special chars in label
|
||||
let label_safe: String = str_replace(str_replace(str_replace(label_trunc, "&", "&"), "<", "<"), ">", ">")
|
||||
let label_y: String = format_float(y + int_to_float(radius) + int_to_float(12), 1)
|
||||
"<circle cx=\"" + xs + "\" cy=\"" + ys + "\" r=\"" + rs + "\" " +
|
||||
"fill=\"" + color + "\" fill-opacity=\"0.85\" " +
|
||||
"stroke=\"" + color + "\" stroke-width=\"1.5\" filter=\"url(#glow)\"/>" +
|
||||
"<text x=\"" + xs + "\" y=\"" + label_y + "\" " +
|
||||
"text-anchor=\"middle\" font-size=\"9\" fill=\"#8b9aaa\" " +
|
||||
"font-family=\"IBM Plex Mono,monospace\">" + label_safe + "</text>"
|
||||
}
|
||||
|
||||
// ── Position lookup ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Build a flat map from node_id -> position JSON in process state.
|
||||
// Key: "pos_<id>" -> "{\"x\":...,\"y\":...}"
|
||||
|
||||
fn build_position_index(positions_json: String) -> Bool {
|
||||
let count: Int = json_array_len(positions_json)
|
||||
let i: Int = 0
|
||||
while i < count {
|
||||
let pos: String = json_array_get(positions_json, i)
|
||||
let id: String = json_get_string(pos, "id")
|
||||
if !str_eq(id, "") {
|
||||
state_set("pos_" + id, pos)
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn get_pos_x(node_id: String) -> Float {
|
||||
let pos: String = state_get("pos_" + node_id)
|
||||
if str_eq(pos, "") { return int_to_float(0) }
|
||||
json_get_float(pos, "x")
|
||||
}
|
||||
|
||||
fn get_pos_y(node_id: String) -> Float {
|
||||
let pos: String = state_get("pos_" + node_id)
|
||||
if str_eq(pos, "") { return int_to_float(0) }
|
||||
json_get_float(pos, "y")
|
||||
}
|
||||
|
||||
// ── Public: graph_render_svg ──────────────────────────────────────────────────
|
||||
|
||||
fn graph_render_svg(nodes_json: String, edges_json: String, positions_json: String, width: Int, height: Int) -> String {
|
||||
// Index positions by node id
|
||||
build_position_index(positions_json)
|
||||
|
||||
let out: String = svg_open(width, height)
|
||||
let out = out + svg_defs()
|
||||
|
||||
// ── Draw edges (behind nodes) ─────────────────────────────────────────────
|
||||
let edge_count: Int = json_array_len(edges_json)
|
||||
let i: Int = 0
|
||||
while i < edge_count {
|
||||
let e: String = json_array_get(edges_json, i)
|
||||
let src: String = edge_source(e)
|
||||
let tgt: String = edge_target(e)
|
||||
let w: Float = edge_weight(e)
|
||||
// Only draw if both endpoints have positions
|
||||
let src_pos: String = state_get("pos_" + src)
|
||||
let tgt_pos: String = state_get("pos_" + tgt)
|
||||
if !str_eq(src_pos, "") {
|
||||
if !str_eq(tgt_pos, "") {
|
||||
let x1: Float = get_pos_x(src)
|
||||
let y1: Float = get_pos_y(src)
|
||||
let x2: Float = get_pos_x(tgt)
|
||||
let y2: Float = get_pos_y(tgt)
|
||||
let out = out + svg_edge(x1, y1, x2, y2, w)
|
||||
}
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
|
||||
// ── Draw nodes (over edges) ───────────────────────────────────────────────
|
||||
let node_count: Int = json_array_len(nodes_json)
|
||||
let j: Int = 0
|
||||
while j < node_count {
|
||||
let n: String = json_array_get(nodes_json, j)
|
||||
let id: String = node_id(n)
|
||||
let lbl: String = node_label(n)
|
||||
let ntype: String = node_type_field(n)
|
||||
let sal: Float = node_salience(n)
|
||||
let color: String = node_color(ntype)
|
||||
let radius: Int = node_radius_int(sal)
|
||||
let pos: String = state_get("pos_" + id)
|
||||
if !str_eq(pos, "") {
|
||||
let x: Float = get_pos_x(id)
|
||||
let y: Float = get_pos_y(id)
|
||||
let out = out + svg_node(x, y, radius, color, lbl)
|
||||
}
|
||||
let j = j + 1
|
||||
}
|
||||
|
||||
let out = out + svg_close()
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,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/"
|
||||
}
|
||||
@@ -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, "&", "&")
|
||||
let s = str_replace(s, "<", "<")
|
||||
let s = str_replace(s, ">", ">")
|
||||
let s = str_replace(s, "\"", """)
|
||||
str_replace(s, "'", "'")
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// el-i18n — Localization for el-ui.
|
||||
//
|
||||
// Two-letter language tag + optional region: en, en-US, ar-EG, zh-Hant.
|
||||
// Bundles map keys to either a plain string or a plural-form map.
|
||||
// `t(key)` and `t_plural(key, count)` are the surface API.
|
||||
|
||||
// ── Locale ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let DIR_LTR: String = "ltr"
|
||||
let DIR_RTL: String = "rtl"
|
||||
|
||||
type Locale {
|
||||
language: String // "en"
|
||||
region: String // "US" (may be empty)
|
||||
direction: String // "ltr" | "rtl"
|
||||
}
|
||||
|
||||
fn locale_new(language: String, region: String) -> Locale {
|
||||
let dir: String = "ltr"
|
||||
if str_eq(language, "ar") { let dir = "rtl" }
|
||||
if str_eq(language, "he") { let dir = "rtl" }
|
||||
if str_eq(language, "fa") { let dir = "rtl" }
|
||||
if str_eq(language, "ur") { let dir = "rtl" }
|
||||
{ "language": language, "region": region, "direction": dir }
|
||||
}
|
||||
|
||||
fn locale_en_us() -> Locale { locale_new("en", "US") }
|
||||
fn locale_es_es() -> Locale { locale_new("es", "ES") }
|
||||
fn locale_zh_cn() -> Locale { locale_new("zh", "CN") }
|
||||
fn locale_ar_eg() -> Locale { locale_new("ar", "EG") }
|
||||
fn locale_ja_jp() -> Locale { locale_new("ja", "JP") }
|
||||
|
||||
fn locale_tag(loc: Locale) -> String {
|
||||
if str_eq(loc.region, "") { return loc.language }
|
||||
loc.language + "-" + loc.region
|
||||
}
|
||||
|
||||
fn is_rtl(loc: Locale) -> Bool {
|
||||
str_eq(loc.direction, "rtl")
|
||||
}
|
||||
|
||||
// ── Plural forms (CLDR cardinal categories) ──────────────────────────────────
|
||||
//
|
||||
// Categories: zero, one, two, few, many, other.
|
||||
// Most languages only use one + other; Arabic uses all six.
|
||||
|
||||
let PLURAL_ZERO: String = "zero"
|
||||
let PLURAL_ONE: String = "one"
|
||||
let PLURAL_TWO: String = "two"
|
||||
let PLURAL_FEW: String = "few"
|
||||
let PLURAL_MANY: String = "many"
|
||||
let PLURAL_OTHER: String = "other"
|
||||
|
||||
fn plural_form(loc: Locale, n: Int) -> String {
|
||||
if str_eq(loc.language, "ar") { return plural_form_arabic(n) }
|
||||
if str_eq(loc.language, "ru") { return plural_form_russian(n) }
|
||||
if str_eq(loc.language, "pl") { return plural_form_polish(n) }
|
||||
if str_eq(loc.language, "ja") { return PLURAL_OTHER }
|
||||
if str_eq(loc.language, "zh") { return PLURAL_OTHER }
|
||||
if str_eq(loc.language, "ko") { return PLURAL_OTHER }
|
||||
// Default English-like rule
|
||||
if n == 1 { return PLURAL_ONE }
|
||||
PLURAL_OTHER
|
||||
}
|
||||
|
||||
fn plural_form_arabic(n: Int) -> String {
|
||||
if n == 0 { return PLURAL_ZERO }
|
||||
if n == 1 { return PLURAL_ONE }
|
||||
if n == 2 { return PLURAL_TWO }
|
||||
let mod100: Int = n - ((n / 100) * 100)
|
||||
if mod100 >= 3 {
|
||||
if mod100 <= 10 { return PLURAL_FEW }
|
||||
}
|
||||
if mod100 >= 11 {
|
||||
if mod100 <= 99 { return PLURAL_MANY }
|
||||
}
|
||||
PLURAL_OTHER
|
||||
}
|
||||
|
||||
fn plural_form_russian(n: Int) -> String {
|
||||
let mod10: Int = n - ((n / 10) * 10)
|
||||
let mod100: Int = n - ((n / 100) * 100)
|
||||
if mod10 == 1 {
|
||||
if mod100 == 11 { return PLURAL_MANY }
|
||||
return PLURAL_ONE
|
||||
}
|
||||
if mod10 >= 2 {
|
||||
if mod10 <= 4 {
|
||||
if mod100 >= 12 {
|
||||
if mod100 <= 14 { return PLURAL_MANY }
|
||||
}
|
||||
return PLURAL_FEW
|
||||
}
|
||||
}
|
||||
PLURAL_MANY
|
||||
}
|
||||
|
||||
fn plural_form_polish(n: Int) -> String {
|
||||
if n == 1 { return PLURAL_ONE }
|
||||
let mod10: Int = n - ((n / 10) * 10)
|
||||
let mod100: Int = n - ((n / 100) * 100)
|
||||
if mod10 >= 2 {
|
||||
if mod10 <= 4 {
|
||||
if mod100 >= 12 {
|
||||
if mod100 <= 14 { return PLURAL_MANY }
|
||||
}
|
||||
return PLURAL_FEW
|
||||
}
|
||||
}
|
||||
PLURAL_MANY
|
||||
}
|
||||
|
||||
// ── Translation bundle ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Stored as a JSON map: key -> value | { one: "...", other: "..." }
|
||||
// `bundle_load_toml` parses a TOML file at load time (planned runtime fn).
|
||||
|
||||
fn bundle_new() -> String {
|
||||
"{}"
|
||||
}
|
||||
|
||||
fn bundle_insert(bundle: String, key: String, value: String) -> String {
|
||||
json_set(bundle, key, "\"" + value + "\"")
|
||||
}
|
||||
|
||||
fn bundle_insert_plural(bundle: String, key: String, plural_map_json: String) -> String {
|
||||
json_set(bundle, key, plural_map_json)
|
||||
}
|
||||
|
||||
fn bundle_load_toml(path: String) -> String {
|
||||
let raw: String = fs_read(path)
|
||||
toml_to_json(raw)
|
||||
}
|
||||
|
||||
// ── LocaleContext + t/t_plural ──────────────────────────────────────────────
|
||||
|
||||
type LocaleContext {
|
||||
locale: Locale
|
||||
bundle: String // JSON
|
||||
fallback_bundle: String
|
||||
}
|
||||
|
||||
fn locale_context_new(loc: Locale, bundle: String) -> LocaleContext {
|
||||
{ "locale": loc, "bundle": bundle, "fallback_bundle": "{}" }
|
||||
}
|
||||
|
||||
fn t(ctx: LocaleContext, key: String) -> String {
|
||||
let v: String = json_get(ctx.bundle, key)
|
||||
if str_eq(v, "") { let v = json_get(ctx.fallback_bundle, key) }
|
||||
if str_eq(v, "") { return key }
|
||||
v
|
||||
}
|
||||
|
||||
fn t_plural(ctx: LocaleContext, key: String, n: Int) -> String {
|
||||
let entry: String = json_get(ctx.bundle, key)
|
||||
if str_eq(entry, "") { return key }
|
||||
let form: String = plural_form(ctx.locale, n)
|
||||
let template: String = json_get(entry, form)
|
||||
if str_eq(template, "") { let template = json_get(entry, PLURAL_OTHER) }
|
||||
if str_eq(template, "") { return key }
|
||||
str_replace(template, "{n}", int_to_str(n))
|
||||
}
|
||||
|
||||
// ── Number / currency formatting ─────────────────────────────────────────────
|
||||
|
||||
fn format_integer(loc: Locale, n: Int) -> String {
|
||||
// Group thousands by the locale's separator. Stub: en uses ',', most EU uses '.'.
|
||||
let sep: String = ","
|
||||
if str_eq(loc.language, "es") { let sep = "." }
|
||||
if str_eq(loc.language, "de") { let sep = "." }
|
||||
if str_eq(loc.language, "fr") { let sep = " " }
|
||||
int_with_separator(n, sep)
|
||||
}
|
||||
|
||||
fn format_number(loc: Locale, n: Int, fraction_digits: Int) -> String {
|
||||
let dec_sep: String = "."
|
||||
if str_eq(loc.language, "es") { let dec_sep = "," }
|
||||
if str_eq(loc.language, "de") { let dec_sep = "," }
|
||||
if str_eq(loc.language, "fr") { let dec_sep = "," }
|
||||
format_integer(loc, n) + dec_sep + repeat_str("0", fraction_digits)
|
||||
}
|
||||
|
||||
fn format_percent(loc: Locale, value_x100: Int) -> String {
|
||||
let body: String = int_to_str(value_x100 / 100) + "."
|
||||
+ int_to_str(value_x100 - ((value_x100 / 100) * 100))
|
||||
if str_eq(loc.language, "fr") { return body + " %" }
|
||||
body + "%"
|
||||
}
|
||||
|
||||
fn format_currency(loc: Locale, amount_minor: Int, iso: String) -> String {
|
||||
// amount_minor is in the smallest unit (cents). Stub formatting only.
|
||||
let major: Int = amount_minor / 100
|
||||
let minor: Int = amount_minor - (major * 100)
|
||||
let body: String = int_to_str(major) + "." + int_to_str(minor)
|
||||
if str_eq(iso, "USD") { return "$" + body }
|
||||
if str_eq(iso, "EUR") { return body + " €" }
|
||||
if str_eq(iso, "GBP") { return "£" + body }
|
||||
if str_eq(iso, "JPY") { return "¥" + int_to_str(amount_minor) }
|
||||
body + " " + iso
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let loc: Locale = locale_en_us()
|
||||
let bundle: String = bundle_new()
|
||||
let bundle = bundle_insert(bundle, "profile.follow", "Follow")
|
||||
let ctx: LocaleContext = locale_context_new(loc, bundle)
|
||||
println("[el-i18n] " + t(ctx, "profile.follow"))
|
||||
@@ -0,0 +1,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/"
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// el-identity — Engram-native identity for el-ui.
|
||||
//
|
||||
// Identity in el-ui is a graph, not a table. Every entity is a node; every
|
||||
// relationship is an edge. Authentication is spreading activation from a
|
||||
// session token through the identity subgraph until it touches a User node.
|
||||
//
|
||||
// Edges:
|
||||
// User ──has_role──▶ Role ──grants──▶ Scope
|
||||
// User ──has_session──▶ Session ──authenticated_via──▶ OAuthToken
|
||||
|
||||
// ── Edge type constants ──────────────────────────────────────────────────────
|
||||
|
||||
let EDGE_HAS_ROLE: String = "has_role"
|
||||
let EDGE_HAS_SESSION: String = "has_session"
|
||||
let EDGE_AUTHENTICATED_VIA: String = "authenticated_via"
|
||||
let EDGE_GRANTS: String = "grants"
|
||||
|
||||
// ── Node type constants ──────────────────────────────────────────────────────
|
||||
|
||||
let NODE_USER: String = "User"
|
||||
let NODE_ROLE: String = "Role"
|
||||
let NODE_SCOPE: String = "Scope"
|
||||
let NODE_OAUTH_TOKEN: String = "OAuthToken"
|
||||
let NODE_SESSION: String = "Session"
|
||||
|
||||
// ── User ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type User {
|
||||
id: String
|
||||
email: String
|
||||
display_name: String
|
||||
created_at: String
|
||||
}
|
||||
|
||||
fn user_new(email: String, display_name: String) -> User {
|
||||
let now: String = time_now_iso()
|
||||
let id: String = uuid_v4()
|
||||
{ "id": id, "email": email, "display_name": display_name, "created_at": now }
|
||||
}
|
||||
|
||||
// ── Role / Scope ─────────────────────────────────────────────────────────────
|
||||
|
||||
type Role {
|
||||
id: String
|
||||
name: String
|
||||
permissions: String // JSON-encoded array; struct fields are flat in El today
|
||||
}
|
||||
|
||||
type Scope {
|
||||
id: String
|
||||
name: String
|
||||
description: String
|
||||
}
|
||||
|
||||
fn role_new(name: String) -> Role {
|
||||
{ "id": uuid_v4(), "name": name, "permissions": "[]" }
|
||||
}
|
||||
|
||||
fn role_has_permission(role: Role, perm: String) -> Bool {
|
||||
str_contains(role.permissions, "\"" + perm + "\"")
|
||||
}
|
||||
|
||||
fn scope_new(name: String, description: String) -> Scope {
|
||||
{ "id": uuid_v4(), "name": name, "description": description }
|
||||
}
|
||||
|
||||
// ── Session ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Session {
|
||||
id: String
|
||||
user_id: String
|
||||
created_at: String
|
||||
expires_at: String
|
||||
ip_address: String
|
||||
}
|
||||
|
||||
fn session_new(user_id: String, ttl_seconds: Int, ip_address: String) -> Session {
|
||||
let now: String = time_now_iso()
|
||||
let exp: String = time_add_seconds(now, ttl_seconds)
|
||||
{ "id": uuid_v4(), "user_id": user_id, "created_at": now, "expires_at": exp, "ip_address": ip_address }
|
||||
}
|
||||
|
||||
fn session_is_expired(session: Session) -> Bool {
|
||||
time_after(time_now_iso(), session.expires_at)
|
||||
}
|
||||
|
||||
// ── OAuthToken ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Tokens are SHA-256 hashed before storage — the raw token never persists.
|
||||
|
||||
type OAuthToken {
|
||||
id: String
|
||||
provider: String
|
||||
access_token_hash: String
|
||||
refresh_token_hash: String
|
||||
expires_at: String
|
||||
scopes: String // JSON-encoded array
|
||||
}
|
||||
|
||||
fn token_hash(raw: String) -> String {
|
||||
sha256_hex(raw)
|
||||
}
|
||||
|
||||
fn oauth_token_new(provider: String, access_raw: String, refresh_raw: String, expires_at: String, scopes: String) -> OAuthToken {
|
||||
let access_h: String = token_hash(access_raw)
|
||||
let refresh_h: String = ""
|
||||
if !str_eq(refresh_raw, "") { let refresh_h = token_hash(refresh_raw) }
|
||||
{ "id": uuid_v4(), "provider": provider, "access_token_hash": access_h,
|
||||
"refresh_token_hash": refresh_h, "expires_at": expires_at, "scopes": scopes }
|
||||
}
|
||||
|
||||
fn oauth_token_is_expired(t: OAuthToken) -> Bool {
|
||||
time_after(time_now_iso(), t.expires_at)
|
||||
}
|
||||
|
||||
// ── PKCE (RFC 7636) ──────────────────────────────────────────────────────────
|
||||
|
||||
type PkceChallenge {
|
||||
verifier: String
|
||||
challenge: String
|
||||
method: String // always "S256"
|
||||
}
|
||||
|
||||
fn pkce_generate() -> PkceChallenge {
|
||||
let verifier: String = base64url_no_pad(random_bytes(32))
|
||||
let chal: String = base64url_no_pad(sha256_bytes(verifier))
|
||||
{ "verifier": verifier, "challenge": chal, "method": "S256" }
|
||||
}
|
||||
|
||||
fn pkce_verify(challenge: String, verifier: String) -> Bool {
|
||||
let computed: String = base64url_no_pad(sha256_bytes(verifier))
|
||||
str_eq(computed, challenge)
|
||||
}
|
||||
|
||||
// ── OAuth providers (Google / GitHub / Apple) ────────────────────────────────
|
||||
//
|
||||
// Stubbed: shape-only. `provider_authorization_url` produces the redirect URL;
|
||||
// `provider_exchange_code` POSTs to the token endpoint via http_post.
|
||||
|
||||
type OAuthProviderCfg {
|
||||
name: String
|
||||
client_id: String
|
||||
client_secret: String
|
||||
auth_url: String
|
||||
token_url: String
|
||||
default_scopes: String
|
||||
}
|
||||
|
||||
fn google_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
|
||||
{ "name": "google", "client_id": client_id, "client_secret": client_secret,
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"default_scopes": "[\"openid\",\"email\",\"profile\"]" }
|
||||
}
|
||||
|
||||
fn github_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
|
||||
{ "name": "github", "client_id": client_id, "client_secret": client_secret,
|
||||
"auth_url": "https://github.com/login/oauth/authorize",
|
||||
"token_url": "https://github.com/login/oauth/access_token",
|
||||
"default_scopes": "[\"read:user\",\"user:email\"]" }
|
||||
}
|
||||
|
||||
fn apple_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
|
||||
{ "name": "apple", "client_id": client_id, "client_secret": client_secret,
|
||||
"auth_url": "https://appleid.apple.com/auth/authorize",
|
||||
"token_url": "https://appleid.apple.com/auth/token",
|
||||
"default_scopes": "[\"name\",\"email\"]" }
|
||||
}
|
||||
|
||||
fn provider_authorization_url(p: OAuthProviderCfg, redirect_uri: String, code_challenge: String, state: String) -> String {
|
||||
p.auth_url + "?response_type=code"
|
||||
+ "&client_id=" + url_encode(p.client_id)
|
||||
+ "&redirect_uri=" + url_encode(redirect_uri)
|
||||
+ "&scope=" + url_encode(json_array_to_space_list(p.default_scopes))
|
||||
+ "&state=" + url_encode(state)
|
||||
+ "&code_challenge=" + url_encode(code_challenge)
|
||||
+ "&code_challenge_method=S256"
|
||||
}
|
||||
|
||||
// ── Engram client interface (graph CRUD) ─────────────────────────────────────
|
||||
//
|
||||
// Identity persists to the local Engram graph. These wrap engram_* runtime
|
||||
// calls (planned). Until that lands, the server-side stub uses a JSON file.
|
||||
|
||||
fn engram_create_node(node_type: String, value_json: String) -> String {
|
||||
engram_node_create(node_type, value_json)
|
||||
}
|
||||
|
||||
fn engram_create_edge(from_id: String, to_id: String, edge_type: String) -> Bool {
|
||||
engram_edge_create(from_id, to_id, edge_type)
|
||||
}
|
||||
|
||||
fn engram_find_connected(node_id: String, edge_type: String) -> String {
|
||||
engram_edge_traverse(node_id, edge_type)
|
||||
}
|
||||
|
||||
// ── OAuth flow coordinator ───────────────────────────────────────────────────
|
||||
|
||||
type AuthFlowParams {
|
||||
pkce_verifier: String
|
||||
pkce_challenge: String
|
||||
redirect_url: String
|
||||
state: String
|
||||
}
|
||||
|
||||
fn begin_auth_flow(provider: OAuthProviderCfg, redirect_uri: String) -> AuthFlowParams {
|
||||
let pkce: PkceChallenge = pkce_generate()
|
||||
let state: String = base64url_no_pad(random_bytes(16))
|
||||
let url: String = provider_authorization_url(provider, redirect_uri, pkce.challenge, state)
|
||||
{ "pkce_verifier": pkce.verifier, "pkce_challenge": pkce.challenge, "redirect_url": url, "state": state }
|
||||
}
|
||||
|
||||
fn exchange_code(provider: OAuthProviderCfg, code: String, pkce_verifier: String, redirect_uri: String, session_id: String) -> OAuthToken {
|
||||
let body: String = "grant_type=authorization_code"
|
||||
+ "&code=" + url_encode(code)
|
||||
+ "&redirect_uri=" + url_encode(redirect_uri)
|
||||
+ "&client_id=" + url_encode(provider.client_id)
|
||||
+ "&client_secret=" + url_encode(provider.client_secret)
|
||||
+ "&code_verifier=" + url_encode(pkce_verifier)
|
||||
let resp: String = http_post(provider.token_url, body)
|
||||
let access: String = json_get(resp, "access_token")
|
||||
let refresh: String = json_get(resp, "refresh_token")
|
||||
let expires_in: Int = str_to_int(json_get(resp, "expires_in"))
|
||||
let exp_at: String = time_add_seconds(time_now_iso(), expires_in)
|
||||
let token: OAuthToken = oauth_token_new(provider.name, access, refresh, exp_at, "[]")
|
||||
let token_id: String = engram_create_node(NODE_OAUTH_TOKEN, json_encode(token))
|
||||
engram_create_edge(session_id, token_id, EDGE_AUTHENTICATED_VIA)
|
||||
token
|
||||
}
|
||||
|
||||
// ── Session manager ──────────────────────────────────────────────────────────
|
||||
|
||||
fn session_create(user_id: String, ttl: Int, ip: String) -> Session {
|
||||
let s: Session = session_new(user_id, ttl, ip)
|
||||
let session_id: String = engram_create_node(NODE_SESSION, json_encode(s))
|
||||
engram_create_edge(user_id, session_id, EDGE_HAS_SESSION)
|
||||
s
|
||||
}
|
||||
|
||||
fn session_revoke(session_id: String) -> Bool {
|
||||
engram_node_delete(session_id)
|
||||
}
|
||||
|
||||
// ── AuthGuard — applied via @authenticate decorator ─────────────────────────
|
||||
|
||||
fn auth_guard_verify(session_id: String) -> Bool {
|
||||
let raw: String = engram_node_get(session_id)
|
||||
if str_eq(raw, "") { return false }
|
||||
let exp: String = json_get(raw, "expires_at")
|
||||
!time_after(time_now_iso(), exp)
|
||||
}
|
||||
|
||||
// ── Identity context (passed through the request lifecycle) ─────────────────
|
||||
|
||||
type IdentityContext {
|
||||
user_id: String
|
||||
session_id: String
|
||||
roles_json: String
|
||||
}
|
||||
|
||||
fn identity_load(session_id: String) -> IdentityContext {
|
||||
let session_raw: String = engram_node_get(session_id)
|
||||
let user_id: String = json_get(session_raw, "user_id")
|
||||
let roles_raw: String = engram_find_connected(user_id, EDGE_HAS_ROLE)
|
||||
{ "user_id": user_id, "session_id": session_id, "roles_json": roles_raw }
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let user: User = user_new("will@neurontechnologies.ai", "Will Anderson")
|
||||
println("[el-identity] user " + user.email + " (" + user.id + ")")
|
||||
@@ -0,0 +1,21 @@
|
||||
// el-layout — Responsive layout engine for el-ui.
|
||||
//
|
||||
// Responsive by default. VStack and HStack wrap automatically.
|
||||
// Grid uses auto columns. You don't write breakpoints for basic layouts.
|
||||
|
||||
vessel "el-layout" {
|
||||
version "0.1.0"
|
||||
description "Stacks, grids, breakpoints, responsive values, safe-area insets"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
el-style "0.1"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// el-platform — Platform abstraction surface for el-ui apps.
|
||||
//
|
||||
// The Rust crate defines `PlatformBackend` trait + per-target render backends
|
||||
// (web/server/ios/android/macos/linux/windows). The El surface is narrower:
|
||||
// El apps reach the host through the runtime's filesystem, network, and OS
|
||||
// primitives. This vessel wraps those into a stable, named API so El callers
|
||||
// don't depend directly on builtin names.
|
||||
//
|
||||
// RUNTIME PARITY GAPS:
|
||||
// - There is no `PlatformBackend` polymorphism at the El layer. The runtime
|
||||
// IS the backend; target selection happens during compilation/packaging,
|
||||
// not at runtime.
|
||||
// - DOM mutation, native widget mounting, event binding — none of those
|
||||
// have El surfaces yet. Use el-ui-compiler when those land.
|
||||
// - `fs_list` returns a JSON array string; consumers must parse with the
|
||||
// forthcoming json_array_get builtin. Until that lands, callers should
|
||||
// treat the value opaquely.
|
||||
|
||||
// ── Targets ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn target_web() -> String { "web" }
|
||||
fn target_server() -> String { "server" }
|
||||
fn target_ios() -> String { "ios" }
|
||||
fn target_android() -> String { "android" }
|
||||
fn target_macos() -> String { "macos" }
|
||||
fn target_linux() -> String { "linux" }
|
||||
fn target_windows() -> String { "windows" }
|
||||
|
||||
// The compiled binary's target is fixed at build time. EL_TARGET is set by
|
||||
// the build harness; absent EL_TARGET, default to "server" (the El runtime
|
||||
// runs as a host process).
|
||||
fn platform_target() -> String {
|
||||
let t: String = env("EL_TARGET")
|
||||
if str_eq(t, "") { return "server" }
|
||||
t
|
||||
}
|
||||
|
||||
fn platform_is_native(t: String) -> Bool {
|
||||
if str_eq(t, "ios") { return true }
|
||||
if str_eq(t, "android") { return true }
|
||||
if str_eq(t, "macos") { return true }
|
||||
if str_eq(t, "linux") { return true }
|
||||
if str_eq(t, "windows") { return true }
|
||||
false
|
||||
}
|
||||
|
||||
fn platform_supports_ssr(t: String) -> Bool {
|
||||
str_eq(t, "server")
|
||||
}
|
||||
|
||||
// ── Errors ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn err_io() -> String { "platform.io" }
|
||||
fn err_network() -> String { "platform.network" }
|
||||
fn err_unsupported() -> String { "platform.unsupported" }
|
||||
fn err_env_missing() -> String { "platform.env_missing" }
|
||||
|
||||
// ── Environment ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn platform_env(key: String) -> String {
|
||||
env(key)
|
||||
}
|
||||
|
||||
fn platform_env_or(key: String, fallback: String) -> String {
|
||||
let v: String = env(key)
|
||||
if str_eq(v, "") { return fallback }
|
||||
v
|
||||
}
|
||||
|
||||
// Strict variant: returns "" if missing, callers branch.
|
||||
// (Runtime has no panic() — fail-soft return preserves type.)
|
||||
fn platform_env_required(key: String) -> String {
|
||||
let v: String = env(key)
|
||||
if str_eq(v, "") {
|
||||
println("[el-platform] ERROR " + err_env_missing() + ":" + key)
|
||||
return ""
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
// ── Filesystem ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn platform_fs_read(path: String) -> String {
|
||||
fs_read(path)
|
||||
}
|
||||
|
||||
fn platform_fs_write(path: String, content: String) -> Bool {
|
||||
fs_write(path, content)
|
||||
true
|
||||
}
|
||||
|
||||
// Returns a JSON array of entry names (opaque until json_array_get lands).
|
||||
fn platform_fs_list(path: String) -> String {
|
||||
let raw: String = fs_list(path)
|
||||
if str_eq(raw, "") { return "[]" }
|
||||
raw
|
||||
}
|
||||
|
||||
fn platform_fs_exists(path: String) -> Bool {
|
||||
let raw: String = fs_read(path)
|
||||
!str_eq(raw, "")
|
||||
}
|
||||
|
||||
// ── HTTP ────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn platform_http_get(url: String) -> String {
|
||||
http_get(url)
|
||||
}
|
||||
|
||||
fn platform_http_post(url: String, body: String) -> String {
|
||||
http_post(url, body)
|
||||
}
|
||||
|
||||
fn platform_http_post_json(url: String, json_body: String) -> String {
|
||||
http_post_json(url, json_body)
|
||||
}
|
||||
|
||||
fn platform_http_get_authed(url: String, bearer: String) -> String {
|
||||
let headers: String = "Authorization: Bearer " + bearer
|
||||
http_get_with_headers(url, headers)
|
||||
}
|
||||
|
||||
fn platform_http_post_authed(url: String, body: String, bearer: String) -> String {
|
||||
let headers: String = "Authorization: Bearer " + bearer
|
||||
http_post_with_headers(url, body, headers)
|
||||
}
|
||||
|
||||
// ── Time / OS clock ─────────────────────────────────────────────────────────
|
||||
|
||||
fn platform_now() -> Int {
|
||||
time_now()
|
||||
}
|
||||
|
||||
fn platform_sleep_ms(ms: Int) -> Bool {
|
||||
sleep_ms(ms)
|
||||
true
|
||||
}
|
||||
|
||||
// ── Identity (for trace/log correlation) ───────────────────────────────────
|
||||
|
||||
fn platform_uuid() -> String {
|
||||
uuid_v4()
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
//
|
||||
// elc-bug-workaround: top-level `let` does not scope into the surrounding
|
||||
// statement substitution in the entry block, so we call platform_target()
|
||||
// inline at each use site.
|
||||
|
||||
println("[el-platform] target=" + platform_target() + " ssr=" + bool_to_str(platform_supports_ssr(platform_target())))
|
||||
println("[el-platform] uuid=" + platform_uuid())
|
||||
@@ -0,0 +1,25 @@
|
||||
// el-publish — App Store and Play Store publishing pipeline.
|
||||
//
|
||||
// One command ships to every platform:
|
||||
// el publish all platforms
|
||||
// el publish --apple App Store only
|
||||
// el publish --google Play Store only
|
||||
// el publish --beta TestFlight + Play internal track
|
||||
|
||||
vessel "el-publish" {
|
||||
version "0.1.0"
|
||||
description "Apple App Store + Google Play publishing automation"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
el-config "0.1"
|
||||
el-secrets "0.1"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// el-publish — App Store + Play Store publishing pipeline.
|
||||
//
|
||||
// Two providers (Apple, Google), one publish flow:
|
||||
// 1. Validate config + certs
|
||||
// 2. Build artifact (delegated to el-ui-compiler / xcodebuild / gradle)
|
||||
// 3. Capture metadata + screenshots
|
||||
// 4. Upload to App Store Connect / Play Developer API
|
||||
// 5. Monitor rollout
|
||||
|
||||
// ── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let PUB_ERR_CONFIG: String = "publish.config"
|
||||
let PUB_ERR_BUILD: String = "publish.build"
|
||||
let PUB_ERR_UPLOAD: String = "publish.upload"
|
||||
let PUB_ERR_CERT: String = "publish.certificate"
|
||||
let PUB_ERR_METADATA: String = "publish.metadata"
|
||||
let PUB_ERR_API: String = "publish.api"
|
||||
let PUB_ERR_IO: String = "publish.io"
|
||||
|
||||
// ── Tracks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let TRACK_PRODUCTION: String = "production"
|
||||
let TRACK_BETA: String = "beta" // TestFlight on Apple; "beta" on Google
|
||||
let TRACK_INTERNAL: String = "internal"
|
||||
let TRACK_ALPHA: String = "alpha"
|
||||
|
||||
// ── Apple config ─────────────────────────────────────────────────────────────
|
||||
|
||||
type AppleConfig {
|
||||
account: String // Apple ID
|
||||
bundle_id: String // ai.neurontechnologies.myapp
|
||||
team_id: String
|
||||
api_key_id: String // App Store Connect API key
|
||||
api_issuer: String
|
||||
}
|
||||
|
||||
// ── Google config ────────────────────────────────────────────────────────────
|
||||
|
||||
type GoogleConfig {
|
||||
package: String // ai.neurontechnologies.myapp
|
||||
track: String // production | beta | internal | alpha
|
||||
service_account_json_ref: String // secret ref to service-account JSON
|
||||
}
|
||||
|
||||
// ── Rollout (staged) ─────────────────────────────────────────────────────────
|
||||
|
||||
type RolloutConfig {
|
||||
initial_percent: Int // 0..100
|
||||
target_percent: Int
|
||||
bake_hours: Int // hours between rollout stages
|
||||
}
|
||||
|
||||
fn rollout_default() -> RolloutConfig {
|
||||
{ "initial_percent": 5, "target_percent": 100, "bake_hours": 24 }
|
||||
}
|
||||
|
||||
// ── Top-level publish config ────────────────────────────────────────────────
|
||||
|
||||
type PublishConfig {
|
||||
version: String
|
||||
build_number: Int
|
||||
apple_json: String // empty if not configured
|
||||
google_json: String
|
||||
rollout_json: String // RolloutConfig as JSON
|
||||
}
|
||||
|
||||
fn publish_config_new(version: String, build_number: Int) -> PublishConfig {
|
||||
{ "version": version, "build_number": build_number,
|
||||
"apple_json": "", "google_json": "", "rollout_json": json_encode(rollout_default()) }
|
||||
}
|
||||
|
||||
// ── Outcome ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type PublishOutcome {
|
||||
platform: String
|
||||
version: String
|
||||
build_number: Int
|
||||
track: String
|
||||
rollout_percent: Int
|
||||
submission_id: String
|
||||
}
|
||||
|
||||
fn outcome_new(platform: String, cfg: PublishConfig, track: String) -> PublishOutcome {
|
||||
let pct: Int = 100
|
||||
if !str_eq(cfg.rollout_json, "") {
|
||||
let pct = str_to_int(json_get(cfg.rollout_json, "initial_percent"))
|
||||
}
|
||||
{ "platform": platform, "version": cfg.version, "build_number": cfg.build_number,
|
||||
"track": track, "rollout_percent": pct, "submission_id": "" }
|
||||
}
|
||||
|
||||
// ── Cert store ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Tracks .p12 / .mobileprovision (Apple) and signing keystores (Google).
|
||||
|
||||
type CertInfo {
|
||||
kind: String // "apple_p12" | "apple_provisioning" | "google_keystore"
|
||||
path: String
|
||||
expires_at: String // ISO 8601
|
||||
fingerprint: String
|
||||
}
|
||||
|
||||
fn cert_is_valid(c: CertInfo) -> Bool {
|
||||
!time_after(time_now_iso(), c.expires_at)
|
||||
}
|
||||
|
||||
fn cert_store_load(dir: String) -> String {
|
||||
// Returns JSON array of CertInfo loaded from `dir`.
|
||||
let files: String = fs_list(dir)
|
||||
let out: String = "[]"
|
||||
let n: Int = json_array_len(files)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let path: String = json_array_get(files, i)
|
||||
let info: CertInfo = cert_inspect(path)
|
||||
let out = json_array_push(out, json_encode(info))
|
||||
let i = i + 1
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cert_inspect(path: String) -> CertInfo {
|
||||
let kind: String = "apple_p12"
|
||||
if str_ends_with(path, ".mobileprovision") { let kind = "apple_provisioning" }
|
||||
if str_ends_with(path, ".jks") { let kind = "google_keystore" }
|
||||
if str_ends_with(path, ".keystore") { let kind = "google_keystore" }
|
||||
let exp: String = exec_capture("openssl", "x509 -enddate -noout -in " + path)
|
||||
{ "kind": kind, "path": path, "expires_at": exp, "fingerprint": "" }
|
||||
}
|
||||
|
||||
// ── Metadata ────────────────────────────────────────────────────────────────
|
||||
|
||||
type StoreMetadata {
|
||||
title: String
|
||||
subtitle: String
|
||||
description: String
|
||||
keywords: String // comma-separated
|
||||
privacy_url: String
|
||||
support_url: String
|
||||
locale: String // e.g. "en-US"
|
||||
}
|
||||
|
||||
fn metadata_load(path: String, locale: String) -> StoreMetadata {
|
||||
let raw: String = fs_read(path + "/" + locale + ".toml")
|
||||
let json: String = toml_to_json(raw)
|
||||
{ "title": json_get(json, "title"),
|
||||
"subtitle": json_get(json, "subtitle"),
|
||||
"description": json_get(json, "description"),
|
||||
"keywords": json_get(json, "keywords"),
|
||||
"privacy_url": json_get(json, "privacy_url"),
|
||||
"support_url": json_get(json, "support_url"),
|
||||
"locale": locale }
|
||||
}
|
||||
|
||||
// ── Screenshots ─────────────────────────────────────────────────────────────
|
||||
|
||||
type ScreenshotTarget {
|
||||
platform: String // "apple" | "google"
|
||||
device_class: String // "iphone-6.7" | "ipad-12.9" | "phone" | "tablet" | "tv"
|
||||
locale: String
|
||||
expected_resolution: String // "1290x2796"
|
||||
}
|
||||
|
||||
fn target_iphone_6_7(locale: String) -> ScreenshotTarget {
|
||||
{ "platform": "apple", "device_class": "iphone-6.7",
|
||||
"locale": locale, "expected_resolution": "1290x2796" }
|
||||
}
|
||||
|
||||
fn target_phone_google(locale: String) -> ScreenshotTarget {
|
||||
{ "platform": "google", "device_class": "phone",
|
||||
"locale": locale, "expected_resolution": "1080x1920" }
|
||||
}
|
||||
|
||||
fn screenshot_capture(target: ScreenshotTarget, simulator_id: String, output_dir: String) -> String {
|
||||
// Drives simulator/emulator to capture at expected resolution.
|
||||
let path: String = output_dir + "/" + target.platform + "_" + target.device_class
|
||||
+ "_" + target.locale + ".png"
|
||||
exec_capture("xcrun", "simctl io " + simulator_id + " screenshot " + path)
|
||||
path
|
||||
}
|
||||
|
||||
// ── Apple publisher ─────────────────────────────────────────────────────────
|
||||
|
||||
fn apple_publish(cfg: PublishConfig, apple: AppleConfig, ipa_path: String, track: String) -> PublishOutcome {
|
||||
let api_key: String = secret_lookup("apple.api_key")
|
||||
let resp: String = exec_capture("xcrun", "altool --upload-app -f " + ipa_path
|
||||
+ " --type ios --apiKey " + apple.api_key_id
|
||||
+ " --apiIssuer " + apple.api_issuer)
|
||||
let outcome: PublishOutcome = outcome_new("apple", cfg, track)
|
||||
let submission: String = json_get(resp, "submission_id")
|
||||
{ "platform": outcome.platform, "version": outcome.version,
|
||||
"build_number": outcome.build_number, "track": outcome.track,
|
||||
"rollout_percent": outcome.rollout_percent, "submission_id": submission }
|
||||
}
|
||||
|
||||
// ── Google publisher ────────────────────────────────────────────────────────
|
||||
|
||||
fn google_publish(cfg: PublishConfig, google: GoogleConfig, aab_path: String) -> PublishOutcome {
|
||||
let sa_json: String = secret_lookup(google.service_account_json_ref)
|
||||
let token: String = google_oauth_token(sa_json)
|
||||
let edit: String = google_play_edit_create(google.package, token)
|
||||
let upload: String = google_play_upload_aab(google.package, edit, aab_path, token)
|
||||
let assigned: String = google_play_track_assign(google.package, edit, google.track,
|
||||
cfg.version, cfg.build_number, token)
|
||||
google_play_edit_commit(google.package, edit, token)
|
||||
let outcome: PublishOutcome = outcome_new("google", cfg, google.track)
|
||||
{ "platform": outcome.platform, "version": outcome.version,
|
||||
"build_number": outcome.build_number, "track": outcome.track,
|
||||
"rollout_percent": outcome.rollout_percent, "submission_id": json_get(upload, "id") }
|
||||
}
|
||||
|
||||
// ── Rollout monitor ─────────────────────────────────────────────────────────
|
||||
|
||||
fn rollout_monitor(outcome: PublishOutcome, cfg: RolloutConfig) -> Bool {
|
||||
// Polls store status, advances rollout percentage in stages.
|
||||
let current: Int = outcome.rollout_percent
|
||||
while current < cfg.target_percent {
|
||||
sleep_seconds(cfg.bake_hours * 3600)
|
||||
let crash_rate: Int = fetch_crash_rate(outcome.platform, outcome.version)
|
||||
if crash_rate > 100 { // 1% crash threshold (per 10000)
|
||||
println("[el-publish] rollout halted: crash rate too high")
|
||||
return false
|
||||
}
|
||||
let next: Int = current + 25
|
||||
if next > cfg.target_percent { let next = cfg.target_percent }
|
||||
rollout_advance(outcome, next)
|
||||
let current = next
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn rollout_advance(outcome: PublishOutcome, percent: Int) -> Bool {
|
||||
println("[el-publish] " + outcome.platform + " rollout -> " + int_to_str(percent) + "%")
|
||||
true
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let cfg: PublishConfig = publish_config_new("1.0.0", 42)
|
||||
let apple: AppleConfig = { "account": "will@neurontechnologies.ai",
|
||||
"bundle_id": "ai.neurontechnologies.myapp", "team_id": "ABC123",
|
||||
"api_key_id": "KEY", "api_issuer": "ISS" }
|
||||
println("[el-publish] " + cfg.version + " (" + int_to_str(cfg.build_number) + ") for " + apple.bundle_id)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user