Skip to content

Compiled HTML templates ​

CryKit compiles ordinary HTML with Crystal expressions into a static template and retained readers and callbacks. The browser constructs the DOM. Crystal tracks each reader's reactive dependencies and updates affected bindings and structural regions when those dependencies change.

Views include CryKit::View and declare their ECR file with template "counter". Document layouts include CryKit::Layout and use template "main". They render once, place the mounted content through <% yield %> exactly once, and have no view lifecycle methods. See layouts and composition for a complete layout example.

Define a view ​

crystal
class Counter
  include CryKit::View

  reactive count : Int32 = 0
  reactive name : String = ""

  template "views/counter"
end
ecr
<section>
  <p>Count: <%= reactive.count %></p>
  <button on_click=<%~ -> { reactive.count += 1 } %>>Increment</button>
  <input binds=<%= reactive.name %>>
  <p>Hello, <%= reactive.name %>!</p>
</section>

Template file paths are relative to the declaring Crystal source file, and the .ecr extension is optional. Expressions can access the view's state and helper methods. Keep rendered mutable state behind reactive getters and setters, including state on shared CryKit::ReactiveState models. reactive.count records a dependency; an ordinary instance variable does not. Events and queued notifications run outside dependency tracking, then publish the invalidated bindings through the native dispatcher.

Expressions and controls ​

<%= expression %> creates a literal text binding. Values reach the browser as text, including strings containing HTML. An attribute expression occupies the whole unquoted value:

ecr
<p title=<%= "Hello #{reactive.name}" %> hidden=<%= reactive.name.empty? %>>
  <%= reactive.name %>
</p>

Nil removes an attribute. Boolean HTML attributes use presence or absence; attributes such as aria-pressed serialize boolean values as strings. Ordinary static HTML, SVG and custom elements remain browser markup.

Reactive URL attributes such as href, src, xlink:href, action, formaction, data, and srcset also validate URL schemes before initial rendering and each update. Relative URLs, http:, https:, app:, and about:blank are accepted. Link attributes additionally accept mailto:, tel:, and sms:. URL lists validate every candidate. Scheme checks account for ASCII whitespace and control characters, including embedded tabs and newlines. Other schemes, including javascript:, data:, and blob:, raise ArgumentError; the failed template update leaves the previous rendered state intact.

For a URL controlled by application code that needs another scheme, explicitly wrap that value:

ecr
<a href=<%= trusted_url("myapp:settings") %>>Settings</a>

The trusted_url helper is available in view and layout templates. It bypasses URL scheme validation for that value while preserving HTML escaping. Do not apply it to imported or user-provided data. Trust does not carry over to later plain string values and cannot enable event-handler attributes or srcdoc. Ordinary literal template markup remains application-controlled HTML.

In a CryKit::Layout, an HTML document title can interpolate scalar values:

ecr
<title><%= document_title %></title>

Title expressions are evaluated once per rendered layout, and their text is escaped. Literal title text and entities are preserved. SVG <title> keeps its ordinary reactive text bindings.

binds accepts a string property such as reactive.name or model.title. The compiler creates separate getter and setter closures. The server acknowledges the canonical value after input, including when validation leaves the previous value unchanged. Newer queued browser edits take precedence over older replies. Set the initial value through the reactive property; binds cannot be combined with a value attribute on an input or textarea.

Callbacks use attributes such as on_click=<%~ callback %> or on_keydown=<%~ callback %>. They can accept no arguments, or the corresponding public event type:

CallbackEvent typePayload
on_clickCryKit::View::ClickEventInternal target id and name ("click")
on_inputCryKit::View::InputEventNon-null string value (also available as data), target id and name ("input")
on_submitCryKit::View::FormEvent(T)Typed data or decoding error, raw_data, target id and name ("submit")
on_keydown, on_keyupCryKit::View::KeyboardEventTarget id, name, typed key, code, location, repeat?, and composing?
on_keyboardCryKit::View::KeyboardEventThe same callback for both keydown and keyup, distinguished with down? and up?
ecr
<input on_input=<%~ ->(event : CryKit::View::InputEvent) { reactive.name = event.value } %>>

String controls normally use binds. These events contain captured data, not browser DOM objects. UI callbacks run sequentially on the UI context through the asynchronous bridge.

Keyboard callbacks target the nearest enabled subscribed element or container within the same mount. A readonly input or textarea can receive keyboard callbacks. Both callback attributes accept the same KeyboardEvent type; name distinguishes "keydown" from "keyup". The typed CryKit::View::KeyboardKey value provides enter?, alt?, ctrl?, meta?, and shift? predicates. Its name is the browser's logical key string, such as "Enter", "Escape", or "a"; string interpolation also renders this name. key.code identifies the physical position and key.location distinguishes standard, left, right, and numpad keys (0–3). These are also available directly as event.code and event.location. Use event.repeat? and event.composing? to inspect repeat and composition state.

Use on_keyboard when one callback should handle both directions:

ecr
<div tabindex="0" on_keyboard=<%~ ->(event : CryKit::View::KeyboardEvent) {
  case event
  when .down?
    key_pressed(event.key.name)
  when .up?
    key_released(event.key.name)
  end
} %>>Press a key</div>

on_keyboard registers both subscriptions. It accepts a zero-argument callback too, and cannot be combined with on_keydown or on_keyup on the same element because each event can have only one callback. Both predicates also work on events delivered to the individual callbacks.

For example, DB Studio runs the current SQL query with Cmd+Enter or Ctrl+Enter:

ecr
<textarea binds=<%= reactive.sql_input %> on_keydown=<%~ ->(event : CryKit::View::KeyboardEvent) { query_keydown(event) } %>></textarea>
crystal
private def query_keydown(event : CryKit::View::KeyboardEvent) : Nil
  return unless event.key.enter? && (event.key.meta? || event.key.ctrl?)
  return if event.key.alt? || event.key.shift? || event.repeat? || event.composing?
  run_query
end

Keyboard snapshots preserve repeats and composition events. Callbacks do not cancel browser default actions or stop DOM propagation. Use binds or on_input for text values; a keydown occurs before its default text edit. Keyboard callbacks apply to elements and containers, rather than window-wide shortcuts. A container must contain the focused element or be focusable itself (for example, with tabindex="0").

Window helpers ​

file_drop_target names an element that accepts native file drops, file_drag_source names a registered file export, and window_drag declares a native window drag region:

ecr
<div file_drop_target="imports">Drop files here</div>
<div file_drag_source="report">Drag the report to Finder</div>
<header window_drag="true">
  <div window_drag="false">Window controls</div>
</header>

These helpers accept the usual whole-value expression syntax:

ecr
<div file_drop_target=<%= reactive.drop_target %>>Drop files here</div>
<div file_drag_source=<%= reactive.export_source %>>Drag the report</div>
<header window_drag=<%= reactive.draggable %>>Workspace</header>

file_drop_target and file_drag_source accept String?; nil removes the declaration. File exports require a matching window.file_drag_source(name, paths) registration; see dragging files out. window_drag accepts Bool?: true enables dragging, false excludes the element's entire subtree, and nil removes the local declaration. A false ancestor excludes a descendant that declares true. Static window_drag values must be "true" or "false".

The compiler emits data-file-drop-target for file_drop_target and a separate attribute for window_drag, preserving other inline or reactive styles. File drops still require file_drop: true on the native window. See file drops and window dragging for event handling, plain HTML declarations, and CSS drag regions.

Form submission ​

Use on_submit on an HTML <form> to handle submission in Crystal. CryKit prevents native form navigation in the browser listener, then sends the event through the existing asynchronous UI queue. Enter, submit buttons and browser constraint validation retain their normal behavior. A form without on_submit keeps its native submission behavior.

Inputs do not need bindings to participate: name identifies each submitted field. The runtime captures a snapshot when the form is submitted, before it waits for earlier callbacks. Later edits do not change that submission.

Define the expected data with an ordinary JSON::Serializable type:

crystal
struct SignupData
  include JSON::Serializable

  getter name : String
  getter email : String
end
ecr
<form on_submit=<%~ ->(event : CryKit::View::FormEvent(SignupData)) { process_form(event) } %>>
  <input name="name" required>
  <input name="email" type="email" required>
  <button type="submit">Save</button>
</form>

The typed handler receives both successful submissions and JSON decoding failures. Handle either result in the view:

crystal
class SignupView
  include CryKit::View

  reactive form_error : String? = nil

  def process_form(event : FormEvent(SignupData))
    case event
    when .error?
      reactive.form_error = event.error.message
    else
      reactive.form_error = nil
      save(event.data)
    end
  end
end

Including CryKit::View makes FormEvent available without qualification. The event exposes:

MethodResult
error?Whether decoding with SignupData.from_json failed.
errorThe original JSON::ParseException on failure; raises on success.
dataThe decoded SignupData on success; raises the stored decoding error on failure.
raw_dataThe original submission as JSON::Any, available in both cases.

JSON::Serializable failures retain their JSON::SerializableError subtype, including klass, attribute, location and cause. Use event.error.as?(JSON::SerializableError) to inspect the schema metadata. The event also exposes id, the internal managed form target, and name, "submit". For an entirely raw handler use CryKit::View::FormEvent(JSON::Any). A zero-argument handler such as on_submit=<%~ -> { save_bound_state } %> can use existing bound state instead.

Form values remain strings; CryKit does not infer numbers or booleans from their contents. Repeated names produce arrays, and multiple-select controls always produce arrays. Disabled and unnamed controls and unchecked checkboxes are omitted. The activated submit button's named value is included. Use normal JSON converters in your data type when another representation is needed. File contents are not supported by this form event payload.

Handled decoding errors use the ordinary update and acknowledgement cycle, so the handler can display an error and accept another submission. CryKit captures JSON::ParseException, including JSON::SerializableError, from decoding. Other exceptions from custom decoders and exceptions raised by the handler follow the normal callback-error behavior and retire a hosted mount. Invalid wire payloads are rejected before the handler is called.

Conditional and keyed regions ​

ecr
<% if reactive.expanded %>
  <p><%= details %></p>
<% elsif reactive.loading %>
  <p>Loading details</p>
<% else %>
  <p>Details hidden</p>
<% end %>

<ul>
  <% visible_items.each do |item| %>
    <li key=<%= item.id %>>
      <span><%= item.title %></span>
      <button on_click=<%~ -> { remove(item.id) } %>>Remove</button>
    </li>
  <% end %>
</ul>

if supports any number of elsif branches and an optional else. Conditions are evaluated in order until one succeeds; later conditions are read only while their branches are reachable. unless supports an optional else. Both forms retain the active branch while the selection is unchanged. Switching branches releases the old bindings and callbacks. Regions may contain zero, one, or several siblings without an extra element wrapper.

Conditions can bind a local with ordinary Crystal assignment syntax:

ecr
<% if matched = current_item %>
  <p><%= matched.title %></p>
<% else %>
  <p>No item selected</p>
<% end %>

The expression runs once per condition evaluation. The local is available in that conditional's branches, with Crystal's usual truthiness narrowing, so the positive branch needs no extra nil check. elsif and unless accept the same syntax. Replacing a truthy value refreshes the retained branch's captured values and callbacks; compatible markup preserves its DOM identities. Switching between truthy and falsey values replaces the branch. The binding stays local to the conditional; later template siblings cannot read it.

Loops use Crystal's ordinary .each do |item| and .each_with_index do |item, index| block syntax. Each loop requires exactly one key=<%= expression %> on an unconditional top-level element in its body. This declares the row's identity using the loop's local variables. The compiler consumes key as template metadata and does not emit it as an HTML attribute. An iteration may still render multiple siblings; the key identifies the entire iteration, so no extra wrapper is required.

For a list whose rows deliberately follow their positions, use the index directly:

ecr
<% reactive.dropped_paths.each_with_index do |path, index| %>
  <li key=<%= index %>><%= path %></li>
<% end %>

Keys are unique within one repeated region. Use a stable item identity when rows should follow their items through reordering. Reordering moves the retained DOM ranges. Replacing an object at an existing key refreshes its readers and callbacks; a compatible template shape keeps its DOM identities. A different shape replaces that item's range. Custom struct equality does not suppress capture updates. Removed items lose their callbacks and dependencies. Reinserting a removed key creates fresh identities.

Composition and lifetime ​

<% render expression %> inserts a View or a Template. Use <% render @child %> to compose a view; CryKit obtains its template internally while preserving the view's lifecycle owner and render context. Document layouts include CryKit::Layout, declare template "main", and place the supplied content through <% yield %> exactly once.

Static composition happens once. Dynamic expressions belong inside text, attribute, conditional or repeated-region readers. A local computed before the template is built retains that original value; put derived calculations in a method called by a binding when they must update.

Before mounting or rebinding, CryKit finalizes the static template graph without evaluating its readers. Cycles and inconsistent marker, binding or control metadata reject the whole graph; unfinalized builders remain writable after rejection. Finalized definitions can be reused across mounts, with independent identities and subscriptions. Collection getters return inspection snapshots; use the builder methods to change a template before finalization. Dynamic child factories remain lazy, and their returned definitions are finalized before instantiation.

Use <% mount reactive.selected_child %> for a reactive view reference that can change. The reference may be nil. Selecting the same view keeps its template, state and update scope; selecting a different view releases the old lifetime and mounts the new one. Construct child views where their intended state lifetime is defined, then expose those retained objects to the template.

Each mounted child view keeps the existing update scope, jobs, subscriptions and cleanup rules. Template callbacks use their owning view's event context, so job.notify, dom.focus and dom.select_text continue to use that lifetime. Static HTML IDs receive managed targets for DOM effects.

Custom template construction ​

For template construction that needs local values or a reusable wrapper, define template explicitly and call the unqualified template macro inside it. ECR expressions can access that method's locals. A wrapper's <% yield %> receives the template returned by the optional block:

crystal
class Panel
  include CryKit::View

  def template
    heading = "Details"
    template "views/panel" do
      template "views/body"
    end
  end
end

Here panel.ecr can use <%= heading %> and <% yield %>, and body.ecr provides the child content. Keep the declaration form, such as template "welcome", for views that only need one ECR file. Template declarations at class scope do not accept blocks.

Compiler boundaries ​

The compiler scans markup and Crystal holes; it delegates DOM construction and namespace handling to the browser. Dynamic tag or attribute names, arbitrary executable ECR statements, and unescaped HTML string insertion are unsupported. The source scanner supports nested delimiters, quoted strings, interpolation, comments, common percent literals, and Crystal's // integer division. Use %r(...) for regular expressions. Crystal syntax and type errors in template expressions identify the original ECR file and source location, including inside conditional branches and keyed loops.

Reactive SVG attributes follow the same name adjustments and namespaces as initial HTML parsing. For example, viewbox updates viewBox, and xlink:href keeps its namespace when removed and restored.

Both anchors of a structural region must parse as ordered siblings under one parent. Insertions use that parent's browser parsing context, including table and SVG contexts. Invalid or duplicate managed markers fail registration before the DOM is published. Raw-text contexts and inert template contents have additional restrictions; compiler errors identify the source file and position.

Evaluation stages values, dependency snapshots and structural candidates before publication. A failed reader, duplicate key or failing child mount hook leaves the committed rendering and callback registry intact. Application state mutations made by a callback are not rolled back.

Dev hotpatch ​

Run crykit dev --watch to enable dev hotpatch for compatible static text and presentation values such as class, style, title, placeholder, alt, role and aria-*. Template structure and every Crystal expression must remain unchanged. These edits keep the process, view state, callbacks, lifecycle and DOM targets. Dev hotreload handles other source changes by compiling and restarting the app. Response-only document layouts rebuild because their template sessions end after the response.

Literal text and attributes retain their original HTML when entity decoding cannot be reproduced safely by the development reader. This includes numeric control or noncharacter references the decoder declines, and incomplete named references such as &copy or &notit;. Edits to those literals rebuild; the browser continues to interpret their original markup in every build.

The watcher classifies each settled save atomically. Mixing a compatible ECR edit with a Crystal or incompatible template edit requests a rebuild. A failed compile keeps the previous app running; compatible presentation edits can still patch it while the code is being corrected.

Asset hotreload handles local CSS and tray icons referenced through asset_path separately. It replaces stylesheets and updates native icons without changing the mounted template or restarting the application.

Built with Crystal. Documented with VitePress.