Skip to content

Latest commit

 

History

106 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

English | Korean | Japanese

glendix

glendix is the JavaScript-target build and rendering bridge for Mendix Pluggable Widgets written in Gleam.

Its package boundary is explicit:

  • glendix owns Pluggable Widgets Tools orchestration, widget definition editing, external npm React bindings, and the Lustre-to-React bridge;
  • mendraw owns Mendix client values and bindings generated from installed .mpk assets;
  • mxpak owns Marketplace search, package download, cache, lockfiles, and workspace deduplication;
  • glendam owns generic browser automation.

Glendix does not implement Marketplace access or browser automation.

Install

[dependencies]
glendix = ">= 6.0.0 and < 7.0.0"

Add mendraw only when the project uses Mendix client values or installed MPK components, and add/use mxpak only when package acquisition is required.

A widget project's package.json normally includes the Mendix Pluggable Widgets Tools and their React peer dependencies.

Typed JSON helpers

glendix/js/json delegates serialization and parsing to gleam_json. Serialization remains source-compatible:

import gleam/json
import glendix/js/json as glendix_json

glendix_json.stringify(value: json.int(42))

Starting with Glendix 6, parsing requires a decoder for the expected result type:

import gleam/dynamic/decode
import glendix/js/json as glendix_json

glendix_json.parse(
  from: "{\"name\":\"glendix\"}",
  using: {
    use name <- decode.field("name", decode.string)
    decode.success(name)
  },
)

Migrate a pre-6 call from parse(from: source) to parse(from: source, using: decoder). Invalid JSON returns InvalidSyntax, while valid JSON that does not match the decoder returns DecoderMismatch. Both error forms are deterministic and do not expose JavaScript-engine exception messages.

Experimental native package managers

Glendix can isolate Mendix Pluggable Widgets Tools from the project's package manager and JavaScript runtime:

[javascript]
runtime = "bun"

[tools.glendix]
pm = "bun"
compatibility = "experimental-native"
pm Gleam runtime Dependency install
npm node npm install
yarn node yarn install
pnpm node pnpm install
bun bun bun install
deno deno hoisted manual deno install with the required lifecycle scripts allowed

In this mode, Glendix invokes the installed Pluggable Widgets Tools CLI with the selected runtime and places temporary node, npm, and npx compatibility shims only on that child process's PATH. The shims satisfy the tool's hard Node/npm checks and route supported install, run, and exec calls back to the selected package manager. They are removed after the command; no global binary, lockfile, or package-manager setting is replaced. Interactive npm lockfile migration is disabled, so the selected manager's lockfile remains authoritative.

This is an explicit experimental compatibility mode, not a complete npm emulator. npm and Bun projects must allow or trust the lifecycle scripts required by the Mendix toolchain, Yarn projects must use the node-modules linker, and pnpm projects must allow the same native build scripts. Deno projects must grant their Gleam commands the required permissions and allow those scripts during install. Invoke dependency modules with an explicit matching runtime, for example gleam run -m glendix/build --runtime bun or --runtime deno; use --runtime node for npm, Yarn, and pnpm.

Basic widget

import mendraw/mendix
import redraw
import redraw/dom/attribute
import redraw/dom/html

pub fn widget(props: mendix.JsProps) -> redraw.Element {
  let title = mendix.get_string_prop(props, "title")
  html.section([attribute.class("widget")], [html.text(title)])
}

This example composes Glendix with Mendraw in the application. Glendix itself remains independently usable for external React bindings and Lustre rendering.

Lustre bridge

import glendix/lustre as glendix_lustre
import gleam/int
import lustre/effect
import lustre/element
import lustre/element/html
import lustre/event
import redraw

type Model { Model(count: Int) }
type Message { Increment }

fn update(model: Model, message: Message) -> #(Model, effect.Effect(Message)) {
  case message {
    Increment -> #(Model(model.count + 1), effect.none())
  }
}

fn view(model: Model) -> element.Element(Message) {
  html.button([event.on_click(Increment)], [
    html.text("Count: " <> int.to_string(model.count)),
  ])
}

pub fn component() -> redraw.Element {
  glendix_lustre.use_tea(#(Model(0), effect.none()), update, view)
}

Props-driven remounts

Use keyed_host when a typed props-derived revision must restart the entire Lustre application:

pub fn component(props: Props) -> redraw.Element {
  glendix_lustre.keyed_host(
    key: props_revision(props),
    props: props,
    render: fn(current_props) {
      glendix_lustre.use_tea(
        init(current_props),
        update,
        view,
      )
    },
  )
}

Derive props_revision in pure Gleam from the application's typed state. An unchanged key preserves the existing application while still passing fresh props to the render callback; a changed key disposes and remounts the hosted application.

keyed_host uses Redraw's React key support but also supplies the component boundary that owns the Lustre hooks. Calling redraw.keyed around an already evaluated use_tea result does not create that boundary. lustre/element/keyed only controls children inside the running Lustre virtual DOM and cannot remount the outer React host.

External npm React components

Configure exports in gleam.toml:

[tools.glendix.bindings]
recharts = ["PieChart", "Pie"]

Install the npm package, then run gleam run -m glendix/install. Glendix owns both component lookup and element construction; Mendraw is not required:

Glendix parses this configuration as standard TOML. Package-manager, compatibility, module, and component names must be quoted TOML strings, and each binding value must be either one string or an array of strings. Malformed TOML, duplicate keys, and unquoted string-like values are rejected instead of being partially interpreted. A missing gleam.toml or missing Glendix table still means that no override or bindings are configured.

import gleam/result
import glendix/binding
import redraw
import redraw/dom/attribute

pub fn pie_chart(
  attributes attributes: List(attribute.Attribute),
  children children: List(redraw.Element),
) -> Result(redraw.Element, binding.BindingError) {
  use module <- result.try(binding.module("recharts"))
  use component <- result.try(binding.resolve(module, "PieChart"))
  Ok(binding.element(component, attributes, children))
}

binding.element_ creates an element with children only, and binding.void_element creates one without children.

Modules that require asynchronous setup use the extended table form:

[tools.glendix.bindings."@ironcalc/workbook"]
exports = ["Workbook"]
initializer = "init"
retry = "on-failure"

initializer names a zero-argument export that must return a Promise. retry is either "never" (the default, which caches a failure until explicit reset) or "on-failure" (the next initialization call may start a new attempt after the failed result has been recorded). exports may be omitted for modules used only through non-React APIs. Legacy string and array values require no initialization and remain ready immediately.

import gleam/javascript/promise
import glendix/binding

pub fn load(
  module module: binding.JsModule,
) -> #(
  binding.ModuleInitialization,
  promise.Promise(Result(Nil, binding.InitializationError)),
) {
  module
  |> binding.initialization
  |> binding.initialize
}

Store the returned ModuleInitialization in the application model. Concurrent calls made with its Initializing value return the same Promise. When that Promise completes, pass its result to binding.settle_initialization; then binding.initialized_module makes the same ready module available to rendering and non-React consumers. binding.initialization_effect dispatches the result through Lustre, while binding.use_initialization consumes the same attempt in a React Suspense boundary. binding.reset_initialization explicitly clears a settled failure (or reinitializes a ready configured module).

The installer retains one generated FFI boundary because the package and export names are read from gleam.toml after Glendix itself has compiled; Gleam @external package/export literals must exist before compilation. That boundary contains deterministic static named imports, metadata lookup, and the direct initializer call only. Promise creation, one-flight sharing, result mapping, retry state, and completion dispatch are implemented in Gleam with gleam/javascript/promise. No dynamic import() or generated Promise cache is used, so Rollup can still discover module WebAssembly assets.

Browser environment and object props

glendix/js/environment reads the browser color-scheme preference behind a typed boundary, so widgets never call window.matchMedia from an application-local FFI. color_scheme reports the operating-system preference as Light, Dark, or System, and resolved_color_scheme resolves what applies as ResolvedLight, ResolvedDark, or ResolutionUnavailable when matchMedia cannot be queried. Dynamic preference-change subscriptions (MediaQueryList change events) are intentionally out of scope; re-query during a props- or revision-driven re-render.

glendix/js/object builds a plain JavaScript object from ordered typed entries, preserving entry order and keeping the last value for a duplicate key. The object passes to an external React component as one prop through a Redraw attribute, without any application-local React FFI:

import glendix/binding
import glendix/js/environment
import glendix/js/object
import redraw
import redraw/dom/attribute

pub fn themed_component(
  component component: binding.JsComponent,
) -> redraw.Element {
  let theme = case environment.resolved_color_scheme() {
    environment.ResolvedDark -> "dark"
    environment.ResolvedLight -> "light"
    environment.ResolutionUnavailable -> "light"
  }
  let configuration =
    object.from_entries([#("theme", object.string(theme))])
  binding.element(
    component,
    [attribute.attribute("config", object.from_object(configuration))],
    [],
  )
}

glendix/js/object is deliberately data-only. Dynamic interop — reading, writing, or deleting arbitrary properties, calling methods, and invoking constructors — lives in the separate glendix/js/reflect module. Reflection is the unsafe/dynamic boundary where the caller, not the type system, guarantees a property or method exists. The __proto__-as-data guarantee applies to object.from_entries; reflective assignment retains ordinary JavaScript setter semantics, so never pass untrusted property names to reflect.set.

Browser file downloads and selection

glendix/js/file creates declarative download resources through gossamer/blob and reads a modern browser file selection through plinth/browser/file_system and plinth/browser/file.

Render downloads as normal Lustre or Redraw anchors with the resource's URL and filename, then call file.release when the anchor is replaced or disposed. Glendix does not create or click a hidden download anchor.

The picker reports PickerUnsupported when showOpenFilePicker is unavailable. Applications that need broader browser support can render a visible file input using the stable, de-duplicated file.accepted_types list; Glendix does not add an imperative hidden-input fallback. Empty files, maximum-size overflow, type mismatch, cancellation, handle failures, and read failures have distinct typed errors. Application parsing and filename policy remain outside Glendix.

See the browser file capability contract for the API examples, validation order, fallback policy, and ecosystem/residual-FFI matrix.

WebAssembly dependencies

Glendix automatically packages browser WebAssembly modules referenced with the standard static URL form used by browser toolchains:

new URL("./engine_bg.wasm", import.meta.url)

The generated Rollup configuration copies each binary into the widget assets/ directory with a deterministic content hash. It rewrites the runtime URL to the correct Mendix route for both AMD and ES module outputs, so the same MPK works in classic and modern web clients. Query strings and fragments are preserved, and repeated references to one binary emit a single asset.

Only static relative .wasm references can be packaged automatically. A missing referenced file fails the build with its module and resolved path. Projects that replace Glendix's generated rollup.config.mjs must compose equivalent asset handling in their custom configuration.

Installed Marketplace widgets

Package acquisition is a separate step owned by mxpak:

[tools.mxpak]
mode = "extract"

[tools.mxpak.widgets.Charts]
version = "3.0.0"
mxp install
gleam run -m mendraw/install
gleam run -m glendix/install
gleam run -m glendix/build
  • mxp install writes package assets to build/widgets/.
  • mendraw/install generates typed MPK bindings.
  • glendix/install installs JavaScript dependencies and generates Glendix npm bindings.
  • glendix/build creates the production .mpk.

Projects that do not use Marketplace widgets omit the first two steps.

Commands

Command Responsibility
gleam run -m glendix/install Install JS dependencies and generate Glendix npm bindings
gleam run -m glendix/define Edit widget property definitions
gleam run -m glendix/dev Run the development build/server
gleam run -m glendix/build Build a production .mpk
gleam run -m glendix/start Connect to the configured Mendix test project
gleam run -m glendix/lint Run lint checks
gleam run -m glendix/lint_fix Apply lint fixes
gleam run -m glendix/release Run the release build

Command execution boundary

glendix/cmd.exec remains a synchronous shell-command API with inherited stdin, stdout, and stderr. Its generic process execution is implemented with shellout; a small platform adapter selects /bin/sh -c on Unix systems and ComSpec /d /s /c on Windows so existing command strings and shell operators keep their behavior. plinth/node/child_process is not used here because its current API does not provide synchronous completion, exit status, and standard stream options as a typed result.

The custom command tooling retained in cmd_ffi.mjs is intentionally Glendix-specific: bridge generation and cleanup, the development watcher, experimental-native runtime setup, Node/npm compatibility shims, and Rollup/WebAssembly handling. Gleam build commands also retain a narrow filtered runner because shellout cannot separately capture and filter stderr while preserving the existing inherited-stream behavior.

Breaking changes in 6.0.0

glendix/js/array now delegates its conversions to gleam/javascript/array and no longer ships a handwritten JavaScript adapter. The from_list and to_list functions keep their names, labels, and element-order behavior, so the common list |> array.from_list |> array.to_list usage is unchanged. The former opaque glendix/js/array.JsArray(element) type is removed; annotate values with gleam/javascript/array.Array(element) instead.

glendix/js/object is now data-only. Its reflection operations moved unchanged to the new glendix/js/reflect module: get, set, delete, has, call_method, call_method_without_arguments, and new_instance, together with the JsConstructor type. Their names, labels, and behavior are preserved, so migrate a pre-split call such as object.get(from: handle, key: "x") to reflect.get(from: handle, key: "x") (add import glendix/js/reflect). Data construction (from_entries, empty, string, int, float, bool, from_object) stays in glendix/js/object, and from_entries keeps its prototype-pollution-safe Object.fromEntries behavior for keys such as __proto__.

Development

See the Glendix FFI boundary reference for every retained, package-adapted, and test-only external contract.

gleam deps download
python3 scripts/check-ffi-contracts.py
gleam format --check
gleam check
gleam build --warnings-as-errors
gleam docs build
gleam test --runtime bun
bun test test/editor_config_ffi_test.mjs

License

MIT License

Releases

Packages

Contributors

Languages