Skip to content

Commit 6df142a

Browse files
committed
Platform-agnostic template AST system
New template syntax with {{ }} interpolation and :directive attributes: <h1>{{ post.title }}</h1> <div :if="post.featured">Featured</div> <li :for="item in items">{{ item.name }}</li> <post-card :title="post.title" @click="view"/> Six AST node types: element, text, expression, conditional, loop, fragment. Phase 1: Template parser (Floki-based HTML parsing + directive/interpolation extraction) Phase 2: Component expander (inline components at publish, rewrite binding paths) Phase 3: AST storage (V009 migration adds ast JSONB column to pages, snapshots, components, layouts) Phase 4: LiveView client compiler under Beacon.Client namespace (AST → HTML, fully decoupled for extraction) Phase 5: AST API endpoint (GET /api/ast/:site/*path serves AST + layout + CSS + event handlers) Built-in filters: format_date, time_ago, truncate, upcase, downcase, strip_html, pluralize, format_number, size, join, first, last, default, json 494 tests, 0 failures.
1 parent 6bb531f commit 6df142a

22 files changed

Lines changed: 2233 additions & 2 deletions

lib/beacon/client.ex

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
defmodule Beacon.Client do
2+
@moduledoc """
3+
Platform-specific client for rendering Beacon's platform-agnostic AST.
4+
5+
This module and all `Beacon.Client.*` submodules are designed for
6+
extraction into a separate library. They have no compile-time dependencies
7+
on Beacon core internals — they consume the JSON AST format and produce
8+
framework-native output.
9+
10+
Currently implements the LiveView/Phoenix client. Future clients (React,
11+
Vue, Rails, etc.) would implement the same AST consumption contract in
12+
their respective languages.
13+
"""
14+
end

lib/beacon/client/filters.ex

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
defmodule Beacon.Client.Filters do
2+
@moduledoc """
3+
Built-in filter implementations for Beacon template rendering.
4+
5+
Filters are the template's only data transformation mechanism.
6+
Every client SDK must implement the same set.
7+
8+
## Available Filters
9+
10+
**Date:** `format_date`, `time_ago`
11+
**Text:** `truncate`, `upcase`, `downcase`, `strip_html`, `pluralize`
12+
**Numbers:** `format_number`
13+
**Collections:** `size`, `join`, `first`, `last`
14+
**Utility:** `default`, `json`
15+
"""
16+
17+
@doc """
18+
Apply a named filter to a value with the given arguments.
19+
"""
20+
@spec apply(binary(), term(), [term()]) :: term()
21+
def apply(name, value, args \\ [])
22+
23+
# -- Date --
24+
25+
def apply("format_date", value, [format]) when is_binary(value) do
26+
case DateTime.from_iso8601(value) do
27+
{:ok, dt, _} -> Calendar.strftime(dt, format)
28+
_ ->
29+
case NaiveDateTime.from_iso8601(value) do
30+
{:ok, ndt} -> Calendar.strftime(ndt, format)
31+
_ -> value
32+
end
33+
end
34+
end
35+
36+
def apply("format_date", %DateTime{} = dt, [format]), do: Calendar.strftime(dt, format)
37+
def apply("format_date", %NaiveDateTime{} = ndt, [format]), do: Calendar.strftime(ndt, format)
38+
def apply("format_date", value, _), do: to_string(value)
39+
40+
def apply("time_ago", value, _args) when is_binary(value) do
41+
case DateTime.from_iso8601(value) do
42+
{:ok, dt, _} -> time_ago_in_words(dt)
43+
_ -> value
44+
end
45+
end
46+
47+
def apply("time_ago", %DateTime{} = dt, _), do: time_ago_in_words(dt)
48+
def apply("time_ago", value, _), do: to_string(value)
49+
50+
# -- Text --
51+
52+
def apply("truncate", value, [length]) when is_binary(value) and is_integer(length) do
53+
if String.length(value) > length do
54+
String.slice(value, 0, length) <> "..."
55+
else
56+
value
57+
end
58+
end
59+
60+
def apply("truncate", value, _), do: to_string(value)
61+
62+
def apply("upcase", value, _) when is_binary(value), do: String.upcase(value)
63+
def apply("upcase", value, _), do: to_string(value)
64+
65+
def apply("downcase", value, _) when is_binary(value), do: String.downcase(value)
66+
def apply("downcase", value, _), do: to_string(value)
67+
68+
def apply("strip_html", value, _) when is_binary(value) do
69+
Regex.replace(~r/<[^>]*>/, value, "")
70+
end
71+
72+
def apply("strip_html", value, _), do: to_string(value)
73+
74+
def apply("pluralize", count, [singular, plural]) when is_integer(count) do
75+
if count == 1, do: "#{count} #{singular}", else: "#{count} #{plural}"
76+
end
77+
78+
def apply("pluralize", value, _), do: to_string(value)
79+
80+
# -- Numbers --
81+
82+
def apply("format_number", value, []) when is_integer(value) do
83+
value
84+
|> Integer.to_string()
85+
|> add_thousands_separator()
86+
end
87+
88+
def apply("format_number", value, [precision]) when is_float(value) and is_integer(precision) do
89+
:erlang.float_to_binary(value, decimals: precision)
90+
end
91+
92+
def apply("format_number", value, _), do: to_string(value)
93+
94+
# -- Collections --
95+
96+
def apply("size", value, _) when is_list(value), do: length(value)
97+
def apply("size", value, _) when is_binary(value), do: String.length(value)
98+
def apply("size", value, _) when is_map(value), do: map_size(value)
99+
def apply("size", _, _), do: 0
100+
101+
def apply("join", value, [separator]) when is_list(value) do
102+
Enum.join(value, separator)
103+
end
104+
105+
def apply("join", value, []) when is_list(value), do: Enum.join(value, ", ")
106+
def apply("join", value, _), do: to_string(value)
107+
108+
def apply("first", [head | _], _), do: head
109+
def apply("first", _, _), do: nil
110+
111+
def apply("last", list, _) when is_list(list) and length(list) > 0, do: List.last(list)
112+
def apply("last", _, _), do: nil
113+
114+
# -- Utility --
115+
116+
def apply("default", nil, [fallback]), do: fallback
117+
def apply("default", "", [fallback]), do: fallback
118+
def apply("default", false, [fallback]), do: fallback
119+
def apply("default", value, _), do: value
120+
121+
def apply("json", value, _), do: Jason.encode!(value)
122+
123+
# -- Unknown filter --
124+
125+
def apply(_name, value, _args), do: value
126+
127+
# -- Private helpers --
128+
129+
defp time_ago_in_words(dt) do
130+
diff = DateTime.diff(DateTime.utc_now(), dt, :second)
131+
132+
cond do
133+
diff < 60 -> "just now"
134+
diff < 3600 -> "#{div(diff, 60)} minutes ago"
135+
diff < 86400 -> "#{div(diff, 3600)} hours ago"
136+
diff < 2_592_000 -> "#{div(diff, 86400)} days ago"
137+
diff < 31_536_000 -> "#{div(diff, 2_592_000)} months ago"
138+
true -> "#{div(diff, 31_536_000)} years ago"
139+
end
140+
end
141+
142+
defp add_thousands_separator(str) do
143+
str
144+
|> String.reverse()
145+
|> String.graphemes()
146+
|> Enum.chunk_every(3)
147+
|> Enum.join(",")
148+
|> String.reverse()
149+
end
150+
end

0 commit comments

Comments
 (0)