Packages
mob
0.6.22
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.2
0.6.1
0.6.0
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.11
0.5.10
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.0
BEAM-on-device mobile framework for Elixir
Current section
Files
Jump to
Current section
Files
lib/mob/sigil.ex
defmodule Mob.Sigil do
@moduledoc """
The `~MOB` sigil for declarative native UI.
Compiles a tag template to a `Mob.Renderer`-compatible node map **at compile
time** using NimbleParsec. Expressions in `{...}` are evaluated in the
caller's scope at runtime.
Use `~MOB(...)` for single nodes or `~MOB\"""...\"""` for nested layouts.
## Examples
import Mob.Sigil
# Self-closing
~MOB(<Text text="Hello" />)
#=> %{type: :text, props: %{text: "Hello"}, children: []}
# Nested layout
~MOB\"""
<Column padding={:space_md}>
<Text text="Title" text_size={:xl} />
<Button text="OK" on_tap={{self(), :ok}} />
</Column>
\"""
# Expression child — inject any node map or list of maps
~MOB\"""
<Column>
{Enum.map(items, fn i -> ~MOB(<Text text={i} />) end)}
</Column>
\"""
## Tag whitelist
Tags are validated against `priv/tags/ios.txt` and `priv/tags/android.txt` at
compile time. Unknown tags emit a warning but still pass through — the type
atom is derived by converting PascalCase to snake_case (e.g. `TabBar` →
`:tab_bar`). This allows new native tags to be used before the whitelist is
updated.
"""
# ── Whitelist ────────────────────────────────────────────────────────────────
@known_tags (
ios_file = Application.app_dir(:mob, "priv/tags/ios.txt")
android_file = Application.app_dir(:mob, "priv/tags/android.txt")
parse_tags = fn file ->
if File.exists?(file) do
file
|> File.read!()
|> String.split("\n", trim: true)
|> Enum.reject(&String.starts_with?(&1, "#"))
|> MapSet.new()
else
MapSet.new()
end
end
ios_tags = parse_tags.(ios_file)
android_tags = parse_tags.(android_file)
%{
ios: ios_tags,
android: android_tags,
both: MapSet.union(ios_tags, android_tags)
}
)
# ── Parser (NimbleParsec) ────────────────────────────────────────────────────
import NimbleParsec
whitespace = ascii_string([?\s, ?\t, ?\n, ?\r], min: 1)
opt_ws = optional(whitespace)
# Tag name: starts with uppercase letter
tag_name =
ascii_char([?A..?Z])
|> ascii_string([?a..?z, ?A..?Z, ?0..?9, ?.], min: 0)
|> reduce({List, :to_string, []})
|> label("tag name starting with uppercase letter")
# Attribute name
attr_name =
ascii_char([?a..?z, ?A..?Z, ?_])
|> ascii_string([?a..?z, ?A..?Z, ?0..?9, ?_], min: 0)
|> reduce({List, :to_string, []})
|> label("attribute name")
# String attribute value: "..."
#
# `utf8_string/2` (not `ascii_string/2`) so non-ASCII bytes in the
# template source — em-dash, en-dash, smart quotes, accented letters,
# emoji — are matched as UTF-8 codepoints and emitted as UTF-8 binary
# segments. `ascii_string([not: ?"])` accepts those bytes but its
# `integer` body re-encodes each one as a Latin-1 codepoint then UTF-8
# (so `–` E2 80 93 comes out as C3 A2 C2 80 C2 93 — double-encoded).
string_value =
ignore(ascii_char([?"]))
|> utf8_string([not: ?"], min: 0)
|> ignore(ascii_char([?"]))
|> tag(:string_val)
# Expression attribute value: {...} with balanced brace support
# Uses parsec(:brace_content) — defined below via defparsec
expr_value =
ignore(ascii_char([?{]))
|> parsec(:brace_content)
|> ignore(ascii_char([?}]))
|> tag(:expr_val)
attr_value = choice([string_value, expr_value])
# Single attribute: name="val" or name={expr}
attribute =
opt_ws
|> ignore()
|> concat(attr_name)
|> ignore(ascii_char([?=]))
|> concat(attr_value)
|> tag(:attr)
attributes = repeat(attribute)
# Expression child: {some_expr} with balanced brace support
expr_child =
ignore(ascii_char([?{]))
|> parsec(:brace_content)
|> ignore(ascii_char([?}]))
|> tag(:expr_child)
# Self-closing tag: <Tag attrs />
self_closing =
ignore(ascii_char([?<]))
|> concat(tag_name)
|> concat(attributes)
|> ignore(opt_ws)
|> ignore(string("/>"))
|> tag(:self_closing)
# Close tag: </Tag>
close_tag =
ignore(string("</"))
|> concat(tag_name)
|> ignore(opt_ws)
|> ignore(ascii_char([?>]))
|> tag(:close_tag)
# A child is either a nested node (self-closing or open) or an expression slot
# We use parsec/1 for recursion into node/0 defined below.
child =
choice([
parsec(:node),
expr_child
])
children = repeat(ignore(opt_ws) |> concat(child))
# Full element: <Tag attrs>children</Tag>
element =
ignore(ascii_char([?<]))
|> concat(tag_name)
|> concat(attributes)
|> ignore(opt_ws)
|> ignore(ascii_char([?>]))
|> tag(:open_part)
|> concat(children)
|> ignore(opt_ws)
|> concat(close_tag)
|> tag(:element)
# Balanced brace content: captures everything between an outer { } pair,
# preserving inner { } pairs recursively. Returns a single joined string.
# Used by expr_value and expr_child so that {%{a: 1}} and {fn -> ... end} work.
# `utf8_string/2` (not `ascii_string/2`) — same reason as `string_value`
# above: brace content can be arbitrary Elixir source including literal
# non-ASCII strings, and `ascii_string` would double-encode those bytes
# before they reach `Code.string_to_quoted!/2`.
defparsec(
:brace_content,
repeat(
choice([
utf8_string([not: ?{, not: ?}], min: 1),
string("{")
|> parsec(:brace_content)
|> string("}")
|> reduce({Enum, :join, [""]})
])
)
|> reduce({Enum, :join, [""]})
)
defparsec(
:node,
choice([
self_closing,
element
])
)
defparsec(
:parse_template,
ignore(opt_ws)
|> parsec(:node)
|> ignore(opt_ws)
|> eos()
)
# ── Macro ────────────────────────────────────────────────────────────────────
@doc """
Compiles a `~MOB(...)` or `~MOB\"""...\"""` template into a native UI node map.
Parsed at compile time; `{expr}` values evaluated at runtime in the caller's scope.
"""
defmacro sigil_MOB({:<<>>, _meta, [template]}, _modifiers) when is_binary(template) do
caller = __CALLER__
case parse_template(String.trim(template)) do
{:ok, [node], "", _, _, _} ->
build_ast(node, caller)
{:ok, _, rest, _, _, _} ->
raise CompileError,
file: caller.file,
line: caller.line,
description: "~MOB: unexpected input near: #{inspect(String.slice(rest, 0, 40))}"
{:error, reason, rest, _, _, _} ->
raise CompileError,
file: caller.file,
line: caller.line,
description: "~MOB: #{reason} near: #{inspect(String.slice(rest, 0, 40))}"
end
end
# ── AST builder ─────────────────────────────────────────────────────────────
defp build_ast({:self_closing, parts}, caller) do
{tag, attrs} = split_tag_attrs(parts)
type = resolve_type(tag, caller)
props = build_props_ast(attrs, caller)
quote do: %{type: unquote(type), props: unquote(props), children: []}
end
defp build_ast({:element, parts}, caller) do
# parts: [{:open_part, [tag, ...attrs]}, ...children..., {:close_tag, [close_tag]}]
{open_part, rest} = List.keytake(parts, :open_part, 0)
{close_tag, rest2} = List.keytake(rest, :close_tag, 0)
{:open_part, open_parts} = open_part
{:close_tag, [close_name]} = close_tag
{tag, attrs} = split_tag_attrs(open_parts)
unless tag == close_name do
raise CompileError,
file: caller.file,
line: caller.line,
description: "~MOB: mismatched tags <#{tag}> ... </#{close_name}>"
end
type = resolve_type(tag, caller)
props = build_props_ast(attrs, caller)
children_ast = build_children_ast(rest2, caller)
quote do: %{type: unquote(type), props: unquote(props), children: unquote(children_ast)}
end
defp split_tag_attrs([tag | attrs]), do: {tag, attrs}
defp build_props_ast(attrs, caller) do
pairs =
Enum.map(attrs, fn {:attr, [name, value_tag]} ->
key = String.to_atom(name)
val = build_value_ast(value_tag, caller)
{key, val}
end)
{:%{}, [], pairs}
end
defp build_value_ast({:string_val, [str]}, _caller), do: str
defp build_value_ast({:expr_val, [expr_str]}, caller) do
Code.string_to_quoted!(String.trim(expr_str), file: caller.file, line: caller.line)
end
defp build_children_ast(children, caller) do
child_asts =
Enum.map(children, fn
{:expr_child, [expr_str]} ->
quoted =
Code.string_to_quoted!(String.trim(expr_str), file: caller.file, line: caller.line)
# Emit a call to `wrap_child/1` rather than an inline `case`.
# The inline version generates a `case` per call site whose
# `is_list(list)` clause is type-narrowed to "unreachable"
# whenever the user's expression has a static-shape return
# (e.g. `nav_button("Foo", :bar)` is always a map). The
# warning is correct in isolation but the multi-shape
# tolerance is the WHOLE POINT of {expr} children — users
# can write `Enum.map(items, &row/1)` and get list-flattened
# behaviour. Dispatching via a helper hides the
# type-narrowing from the per-call-site warning while
# preserving both shapes' runtime behaviour.
quote do: Mob.Sigil.wrap_child(unquote(quoted))
node_tuple ->
ast = build_ast(node_tuple, caller)
quote do: [unquote(ast)]
end)
quote do: List.flatten(unquote(child_asts))
end
@doc """
Normalizes a `{expr}` child's value to a list of UI-node maps for
the surrounding sigil. Single nodes wrap into a one-element list;
lists pass through. Public so the sigil-generated AST can call it
by FQ name; not part of the application API.
"""
@spec wrap_child(list() | map()) :: list()
def wrap_child(list) when is_list(list), do: list
def wrap_child(node), do: [node]
defp resolve_type(tag, caller) do
atom = tag |> Macro.underscore() |> String.to_atom()
unless MapSet.member?(@known_tags.both, tag) do
ios_only =
MapSet.member?(@known_tags.ios, tag) and not MapSet.member?(@known_tags.android, tag)
android_only =
MapSet.member?(@known_tags.android, tag) and not MapSet.member?(@known_tags.ios, tag)
msg =
cond do
ios_only -> "~MOB: <#{tag}> is iOS-only — not supported on Android"
android_only -> "~MOB: <#{tag}> is Android-only — not supported on iOS"
true -> "~MOB: <#{tag}> is not in the Mob tag whitelist — pass-through as :#{atom}"
end
IO.warn(msg, Macro.Env.stacktrace(caller))
end
atom
end
end