grsx 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +20 -0
- data/.gitignore +5 -0
- data/.rspec +2 -0
- data/.travis.yml +6 -0
- data/Appraisals +17 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Dockerfile +8 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +274 -0
- data/Guardfile +70 -0
- data/LICENSE.txt +21 -0
- data/README.md +437 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/bin/test +43 -0
- data/docker-compose.yml +29 -0
- data/gemfiles/.bundle/config +2 -0
- data/gemfiles/rails_6_1.gemfile +8 -0
- data/gemfiles/rails_6_1.gemfile.lock +260 -0
- data/gemfiles/rails_7_0.gemfile +7 -0
- data/gemfiles/rails_7_0.gemfile.lock +265 -0
- data/gemfiles/rails_7_1.gemfile +7 -0
- data/gemfiles/rails_7_1.gemfile.lock +295 -0
- data/gemfiles/rails_7_2.gemfile +7 -0
- data/gemfiles/rails_7_2.gemfile.lock +290 -0
- data/gemfiles/rails_8_0.gemfile +8 -0
- data/gemfiles/rails_8_0.gemfile.lock +344 -0
- data/gemfiles/rails_8_1.gemfile +8 -0
- data/gemfiles/rails_8_1.gemfile.lock +313 -0
- data/gemfiles/rails_master.gemfile +7 -0
- data/gemfiles/rails_master.gemfile.lock +296 -0
- data/grsx.gemspec +43 -0
- data/lib/generators/grsx/phlex_component/phlex_component_generator.rb +40 -0
- data/lib/generators/grsx/phlex_component/templates/component.rb.tt +12 -0
- data/lib/generators/grsx/phlex_component/templates/component.rbx.tt +6 -0
- data/lib/grsx/component_resolver.rb +64 -0
- data/lib/grsx/configuration.rb +14 -0
- data/lib/grsx/lexer.rb +325 -0
- data/lib/grsx/nodes/abstract_attr.rb +12 -0
- data/lib/grsx/nodes/abstract_element.rb +13 -0
- data/lib/grsx/nodes/abstract_node.rb +31 -0
- data/lib/grsx/nodes/component_element.rb +69 -0
- data/lib/grsx/nodes/component_prop.rb +29 -0
- data/lib/grsx/nodes/declaration.rb +15 -0
- data/lib/grsx/nodes/expression.rb +15 -0
- data/lib/grsx/nodes/expression_group.rb +15 -0
- data/lib/grsx/nodes/fragment.rb +30 -0
- data/lib/grsx/nodes/html_attr.rb +13 -0
- data/lib/grsx/nodes/html_element.rb +49 -0
- data/lib/grsx/nodes/newline.rb +9 -0
- data/lib/grsx/nodes/raw.rb +23 -0
- data/lib/grsx/nodes/root.rb +19 -0
- data/lib/grsx/nodes/text.rb +15 -0
- data/lib/grsx/nodes/util.rb +9 -0
- data/lib/grsx/nodes.rb +20 -0
- data/lib/grsx/parser.rb +238 -0
- data/lib/grsx/phlex_compiler.rb +223 -0
- data/lib/grsx/phlex_component.rb +361 -0
- data/lib/grsx/phlex_runtime.rb +70 -0
- data/lib/grsx/prop_inspector.rb +52 -0
- data/lib/grsx/rails/engine.rb +24 -0
- data/lib/grsx/rails/phlex_reloader.rb +25 -0
- data/lib/grsx/template.rb +12 -0
- data/lib/grsx/version.rb +3 -0
- data/lib/grsx.rb +35 -0
- metadata +324 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
require "phlex"
|
|
2
|
+
require "phlex-rails"
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Grsx
|
|
6
|
+
# Base class for JSX-backed Phlex components.
|
|
7
|
+
#
|
|
8
|
+
# Define your props in initialize, write your template in a co-located .rbx
|
|
9
|
+
# file. Grsx compiles the .rbx into a real view_template method — no eval
|
|
10
|
+
# at render time.
|
|
11
|
+
#
|
|
12
|
+
# ## Basic usage
|
|
13
|
+
#
|
|
14
|
+
# # app/components/card_component.rb
|
|
15
|
+
# class CardComponent < Grsx::PhlexComponent
|
|
16
|
+
# def initialize(title:)
|
|
17
|
+
# @title = title
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# # app/components/card_component.rbx
|
|
22
|
+
# <article class="card">
|
|
23
|
+
# <h2>{@title}</h2>
|
|
24
|
+
# {content}
|
|
25
|
+
# </article>
|
|
26
|
+
#
|
|
27
|
+
# ## Named slots
|
|
28
|
+
#
|
|
29
|
+
# class CardComponent < Grsx::PhlexComponent
|
|
30
|
+
# slots :header, :footer
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# # card_component.rbx
|
|
34
|
+
# <article>
|
|
35
|
+
# <header>{slot(:header)}</header>
|
|
36
|
+
# <main>{content}</main>
|
|
37
|
+
# <footer>{slot(:footer)}</footer>
|
|
38
|
+
# </article>
|
|
39
|
+
#
|
|
40
|
+
# # Usage
|
|
41
|
+
# card = CardComponent.new
|
|
42
|
+
# card.with_slot(:header) { render LogoComponent.new }
|
|
43
|
+
# render card
|
|
44
|
+
#
|
|
45
|
+
class PhlexComponent < Phlex::HTML
|
|
46
|
+
# Include ALL phlex-rails helper adapters so link_to, form_with,
|
|
47
|
+
# image_tag, url_for, etc. just work in every .rbx template without
|
|
48
|
+
# per-component opt-in.
|
|
49
|
+
#
|
|
50
|
+
# Some helpers (e.g. Routes) reference `Rails.application` at define
|
|
51
|
+
# time, which raises NameError outside a Rails boot. We rescue and
|
|
52
|
+
# skip — those helpers are unavailable in non-Rails contexts anyway.
|
|
53
|
+
Phlex::Rails::Helpers.constants.each do |helper_name|
|
|
54
|
+
begin
|
|
55
|
+
mod = Phlex::Rails::Helpers.const_get(helper_name)
|
|
56
|
+
include mod if mod.is_a?(Module)
|
|
57
|
+
rescue NameError
|
|
58
|
+
# Skip helpers that require Rails to be fully initialized
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# --- Named slots ---
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
# Declare named content slots on the component.
|
|
66
|
+
#
|
|
67
|
+
# class CardComponent < Grsx::PhlexComponent
|
|
68
|
+
# slots :header, :footer
|
|
69
|
+
# end
|
|
70
|
+
def slots(*names)
|
|
71
|
+
names.each do |name|
|
|
72
|
+
# Define a setter: component.with_header { ... }
|
|
73
|
+
define_method(:"with_#{name}") do |&block|
|
|
74
|
+
@_slots ||= {}
|
|
75
|
+
@_slots[name] = block
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Define a predicate: has_header?
|
|
80
|
+
define_method(:"has_#{name}?") do
|
|
81
|
+
(@_slots ||= {}).key?(name)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Declare typed props with optional defaults — auto-generates initialize.
|
|
87
|
+
#
|
|
88
|
+
# class CardComponent < Grsx::PhlexComponent
|
|
89
|
+
# props :title, :body, size: :md, disabled: false
|
|
90
|
+
# end
|
|
91
|
+
#
|
|
92
|
+
# This is exactly equivalent to:
|
|
93
|
+
#
|
|
94
|
+
# def initialize(title:, body:, size: :md, disabled: false)
|
|
95
|
+
# @title = title
|
|
96
|
+
# @body = body
|
|
97
|
+
# @size = size
|
|
98
|
+
# @disabled = disabled
|
|
99
|
+
# end
|
|
100
|
+
#
|
|
101
|
+
# You can still override initialize manually when you need logic
|
|
102
|
+
# beyond simple ivar assignment.
|
|
103
|
+
def props(*required_names, **defaults)
|
|
104
|
+
# Guard against mutable default values ([], {}) — they would be
|
|
105
|
+
# shared across every instance of the component, causing subtle
|
|
106
|
+
# cross-request state contamination. Fail loudly at class-definition
|
|
107
|
+
# time with guidance on the idiomatic fix.
|
|
108
|
+
defaults.each do |key, val|
|
|
109
|
+
if val.is_a?(Array) || val.is_a?(Hash)
|
|
110
|
+
raise ArgumentError,
|
|
111
|
+
"#{name}.props :#{key} has a mutable default (#{val.inspect}). " \
|
|
112
|
+
"Use nil as the default and set the value in initialize instead:\n" \
|
|
113
|
+
" props :#{key}\n" \
|
|
114
|
+
" def initialize(#{key}: nil)\n" \
|
|
115
|
+
" @#{key} = #{key} || #{val.inspect}\n" \
|
|
116
|
+
" end"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@_declared_props = { required: required_names.map(&:to_sym), defaults: defaults }
|
|
121
|
+
|
|
122
|
+
all_names = required_names.map(&:to_sym) + defaults.keys.map(&:to_sym)
|
|
123
|
+
|
|
124
|
+
# Generate attr_readers so callers can inspect prop values after render.
|
|
125
|
+
# Templates use @ivar directly; attr_reader makes the same data available
|
|
126
|
+
# to parent components or test code.
|
|
127
|
+
attr_reader(*all_names)
|
|
128
|
+
|
|
129
|
+
# Build initialize parameter list
|
|
130
|
+
params = required_names.map { |n| "#{n}:" }
|
|
131
|
+
defaults.each { |k, v| params << "#{k}: #{v.inspect}" }
|
|
132
|
+
|
|
133
|
+
# Build ivar assignment lines
|
|
134
|
+
assignments = all_names.map { |n| " @#{n} = #{n}" }
|
|
135
|
+
|
|
136
|
+
class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
|
|
137
|
+
def initialize(#{params.join(", ")})
|
|
138
|
+
#{assignments.join("\n")}
|
|
139
|
+
end
|
|
140
|
+
RUBY
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Returns the declared props, or nil if none were declared.
|
|
144
|
+
attr_reader :_declared_props
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Render a named slot. Falls back silently if no slot content was provided.
|
|
148
|
+
# Used in .rbx templates as {slot(:header)}.
|
|
149
|
+
def slot(name)
|
|
150
|
+
block = (@_slots ||= {})[name]
|
|
151
|
+
instance_exec(&block) if block
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- Default children slot ---
|
|
156
|
+
|
|
157
|
+
# {content} in a .rbx template compiles to `yield` — Phlex 2.x style.
|
|
158
|
+
# This method is a no-op; the compiler special-cases the `content` identifier.
|
|
159
|
+
# Kept for documentation and as a fallback.
|
|
160
|
+
def content
|
|
161
|
+
yield
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# --- Expression output ---
|
|
165
|
+
|
|
166
|
+
# Handles all { ruby_expr } in compiled templates:
|
|
167
|
+
#
|
|
168
|
+
# render(<Comp />) already wrote to buffer → returns nil → no-op here
|
|
169
|
+
# Array/Enumerable → each element rendered recursively
|
|
170
|
+
# Phlex::SafeObject → raw() (trusted HTML, no escaping)
|
|
171
|
+
# nil / false / "" → silent no-op (safe for && and || patterns)
|
|
172
|
+
# anything else → plain(value.to_s) (CGI auto-escaped, XSS-safe)
|
|
173
|
+
def __rbx_expr_out(value)
|
|
174
|
+
case value
|
|
175
|
+
when nil, false, ""
|
|
176
|
+
nil # {condition && <Foo />}: falsy short-circuit
|
|
177
|
+
when Array, Enumerable
|
|
178
|
+
# {@items.map { |i| <Item /> }}: map returns [nil,nil,...] after render→nil
|
|
179
|
+
value.each { |v| __rbx_expr_out(v) }
|
|
180
|
+
when Phlex::SGML
|
|
181
|
+
# Safety net: if a user passes a component directly (e.g. {MyComp.new})
|
|
182
|
+
# render it normally. Our render override returns nil so this branch is
|
|
183
|
+
# only hit in direct-instance-passing scenarios, not {cond && <Comp />}.
|
|
184
|
+
render(value)
|
|
185
|
+
when Phlex::SGML::SafeObject
|
|
186
|
+
raw(value)
|
|
187
|
+
else
|
|
188
|
+
plain(value.to_s)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Explicit escape hatch for trusted HTML strings.
|
|
193
|
+
#
|
|
194
|
+
# By default, every { expression } is CGI-escaped via plain(). Use safe()
|
|
195
|
+
# when you have a string that is already HTML and must not be escaped:
|
|
196
|
+
#
|
|
197
|
+
# {safe(@html_body)} # raw inject
|
|
198
|
+
# {@items.map { safe(i.html) }} # safe inside map
|
|
199
|
+
#
|
|
200
|
+
# WARNING: never pass user-supplied input to safe() — it bypasses all XSS
|
|
201
|
+
# protection. Only use for strings you have produced or sanitized yourself.
|
|
202
|
+
def safe(html_string)
|
|
203
|
+
Phlex::SGML::SafeValue.new(html_string.to_s)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Override Phlex's render to always return nil.
|
|
207
|
+
#
|
|
208
|
+
# Phlex::SGML#render returns the component instance, which would cause
|
|
209
|
+
# __rbx_expr_out to see a Phlex::SGML and call render() a second time
|
|
210
|
+
# (double-render bug). Returning nil short-circuits that:
|
|
211
|
+
#
|
|
212
|
+
# {true && <Button />} → true && render(ButtonComponent.new) → nil
|
|
213
|
+
# __rbx_expr_out(nil) → no-op ✓
|
|
214
|
+
#
|
|
215
|
+
# {@items.map { |i| <Item /> }} → map returns [nil, nil, nil]
|
|
216
|
+
# __rbx_expr_out([nil, nil, nil]) → no-op ✓
|
|
217
|
+
def render(renderable = nil, &block)
|
|
218
|
+
super
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Raised when a .rbx template fails to compile (syntax or parse error).
|
|
223
|
+
# Provides the source file path and the underlying error message so
|
|
224
|
+
# developers see their .rbx line rather than a grsx internal backtrace.
|
|
225
|
+
class TemplateCompileError < StandardError
|
|
226
|
+
attr_reader :template_path
|
|
227
|
+
|
|
228
|
+
def initialize(message, template_path:)
|
|
229
|
+
@template_path = template_path
|
|
230
|
+
super(message)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# --- Template loading ---
|
|
235
|
+
|
|
236
|
+
class << self
|
|
237
|
+
# Template cache: { path => { mtime: Time, code: String } }
|
|
238
|
+
TEMPLATE_CACHE = {}
|
|
239
|
+
private_constant :TEMPLATE_CACHE
|
|
240
|
+
|
|
241
|
+
def inherited(subclass)
|
|
242
|
+
# Capture the caller's file path BEFORE calling super so the stack
|
|
243
|
+
# frame is still fresh. This is more reliable than source_location
|
|
244
|
+
# because it works even when the subclass has no custom initialize.
|
|
245
|
+
defining_file = caller_locations(1, 10)
|
|
246
|
+
.find { |loc| loc.path != __FILE__ && !loc.path.end_with?("phlex_component.rb") }
|
|
247
|
+
&.path
|
|
248
|
+
subclass.instance_variable_set(:@_rbx_source_rb, defining_file)
|
|
249
|
+
|
|
250
|
+
super
|
|
251
|
+
subclass.load_rbx_template
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Locate, compile, and define view_template from the co-located .rbx file.
|
|
255
|
+
# Called once when the subclass is first defined.
|
|
256
|
+
def load_rbx_template
|
|
257
|
+
path = rbx_template_path
|
|
258
|
+
return unless path && File.exist?(path)
|
|
259
|
+
|
|
260
|
+
compiled = compile_template(path)
|
|
261
|
+
define_view_template(compiled)
|
|
262
|
+
@_rbx_template_path = path
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Recompile and redefine view_template if the .rbx file has changed.
|
|
266
|
+
# Called by Grsx::Rails::PhlexReloader on each dev request.
|
|
267
|
+
def reload_rbx_template_if_changed
|
|
268
|
+
path = @_rbx_template_path
|
|
269
|
+
return unless path
|
|
270
|
+
|
|
271
|
+
mtime = File.mtime(path)
|
|
272
|
+
cached = TEMPLATE_CACHE[path]
|
|
273
|
+
return if cached && cached[:mtime] == mtime
|
|
274
|
+
|
|
275
|
+
compiled = compile_template(path)
|
|
276
|
+
define_view_template(compiled)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Return the path to the .rbx file for this component (nil if not found).
|
|
280
|
+
def rbx_template_path
|
|
281
|
+
@_rbx_template_path if defined?(@_rbx_template_path)
|
|
282
|
+
|
|
283
|
+
source = @_rbx_source_rb
|
|
284
|
+
return nil unless source
|
|
285
|
+
|
|
286
|
+
base = File.basename(source, ".rb")
|
|
287
|
+
dir = File.dirname(source)
|
|
288
|
+
candidate = File.join(dir, "#{base}.rbx")
|
|
289
|
+
candidate if File.exist?(candidate)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# All known PhlexComponent subclasses, for the dev-mode reloader.
|
|
293
|
+
def all_descendants
|
|
294
|
+
ObjectSpace.each_object(Class).select { |c| c < self }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Returns the Phlex DSL Ruby code that was compiled from the .rbx template.
|
|
298
|
+
# Useful for debugging, introspection, and writing specs that verify
|
|
299
|
+
# what the compiler generates:
|
|
300
|
+
#
|
|
301
|
+
# puts MyCard.compiled_template_code
|
|
302
|
+
# # ⇒ div(class: "card") do
|
|
303
|
+
# # plain(@title)
|
|
304
|
+
# # end
|
|
305
|
+
def compiled_template_code
|
|
306
|
+
path = @_rbx_template_path || rbx_template_path
|
|
307
|
+
return nil unless path
|
|
308
|
+
compile_template(path)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
private
|
|
312
|
+
|
|
313
|
+
def compile_template(path)
|
|
314
|
+
content = File.read(path)
|
|
315
|
+
|
|
316
|
+
# Cache by content hash, not mtime. mtime is fragile in Docker/rsync
|
|
317
|
+
# deployments where COPY or rsync can reset timestamps to build time
|
|
318
|
+
# without changing content — or vice versa, touch the file without
|
|
319
|
+
# changing content, causing pointless recompilation.
|
|
320
|
+
#
|
|
321
|
+
# SHA256 is deterministic and correct. We truncate to 16 hex chars
|
|
322
|
+
# (64 bits of collision resistance) which is more than sufficient for
|
|
323
|
+
# a per-process in-memory cache keyed by full path.
|
|
324
|
+
hash = Digest::SHA256.hexdigest(content)[0, 16]
|
|
325
|
+
cache_key = "#{path}:#{hash}"
|
|
326
|
+
|
|
327
|
+
return TEMPLATE_CACHE[cache_key] if TEMPLATE_CACHE.key?(cache_key)
|
|
328
|
+
|
|
329
|
+
template = Grsx::Template.new(content, path)
|
|
330
|
+
|
|
331
|
+
begin
|
|
332
|
+
code = Grsx.compile(template)
|
|
333
|
+
rescue Grsx::Lexer::SyntaxError, Grsx::Parser::ParseError => e
|
|
334
|
+
raise TemplateCompileError.new(
|
|
335
|
+
"#{File.basename(path)}: #{e.message}",
|
|
336
|
+
template_path: path
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
TEMPLATE_CACHE[cache_key] = code
|
|
341
|
+
code
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def define_view_template(compiled_code)
|
|
345
|
+
# Pass the .rbx file path and line 1 to class_eval so that Ruby's
|
|
346
|
+
# backtraces point directly to the template file when errors occur.
|
|
347
|
+
#
|
|
348
|
+
# Before: view_template defined at phlex_component.rb:233 (useless)
|
|
349
|
+
# After: error at card_component.rbx:5:in 'view_template'
|
|
350
|
+
#
|
|
351
|
+
# @_rbx_template_path is set by load_rbx_template before we get here.
|
|
352
|
+
source_file = @_rbx_template_path || __FILE__
|
|
353
|
+
class_eval(<<~RUBY, source_file, 1)
|
|
354
|
+
def view_template
|
|
355
|
+
#{compiled_code}
|
|
356
|
+
end
|
|
357
|
+
RUBY
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "phlex"
|
|
2
|
+
require "phlex-rails"
|
|
3
|
+
|
|
4
|
+
module Grsx
|
|
5
|
+
# A Phlex::HTML subclass that serves as the execution context for compiled
|
|
6
|
+
# .rbx templates when Grsx.configuration.render_target == :phlex.
|
|
7
|
+
#
|
|
8
|
+
# Instead of ActionView's @output_buffer, compiled Phlex-target code calls
|
|
9
|
+
# Phlex element methods directly (div, span, etc.) and renders component
|
|
10
|
+
# instances via render().
|
|
11
|
+
class PhlexRuntime < Phlex::HTML
|
|
12
|
+
# Include ALL phlex-rails helper adapters (mirrors PhlexComponent).
|
|
13
|
+
Phlex::Rails::Helpers.constants.each do |helper_name|
|
|
14
|
+
begin
|
|
15
|
+
mod = Phlex::Rails::Helpers.const_get(helper_name)
|
|
16
|
+
include mod if mod.is_a?(Module)
|
|
17
|
+
rescue NameError
|
|
18
|
+
# Skip helpers that require Rails to be fully initialized
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Called by PhlexCompiler-generated code for inline expressions: {expr}
|
|
23
|
+
#
|
|
24
|
+
# render(<Comp />) already wrote to buffer → returns nil → no-op here
|
|
25
|
+
# Array/Enumerable → each element rendered recursively
|
|
26
|
+
# Phlex::SafeObject → raw()
|
|
27
|
+
# nil / false / "" → silent no-op (safe for &&, ||, ternary)
|
|
28
|
+
# anything else → plain(value.to_s) CGI-escaped
|
|
29
|
+
def __rbx_expr_out(value)
|
|
30
|
+
case value
|
|
31
|
+
when nil, false, ""
|
|
32
|
+
nil
|
|
33
|
+
when Array, Enumerable
|
|
34
|
+
value.each { |v| __rbx_expr_out(v) }
|
|
35
|
+
when Phlex::SGML
|
|
36
|
+
render(value)
|
|
37
|
+
when Phlex::SGML::SafeObject
|
|
38
|
+
raw(value)
|
|
39
|
+
else
|
|
40
|
+
plain(value.to_s)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Return nil from render() so that {cond && <Comp />} doesn't double-render.
|
|
45
|
+
# The actual rendering is a side-effect on the buffer, not the return value.
|
|
46
|
+
def render(renderable = nil, &block)
|
|
47
|
+
super
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(assigns: {})
|
|
52
|
+
assigns.each { |k, v| instance_variable_set("@#{k}", v) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def view_template(&block)
|
|
56
|
+
instance_eval(&block)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Explicit escape hatch for trusted HTML strings (mirrors PhlexComponent#safe).
|
|
60
|
+
# WARNING: never pass user-supplied input to safe() — bypasses XSS protection.
|
|
61
|
+
def safe(html_string)
|
|
62
|
+
Phlex::SGML::SafeValue.new(html_string.to_s)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Rails view_context is accessed via phlex-rails' context[:rails_view_context]
|
|
66
|
+
# which is set automatically during render_in. We no longer carry our own
|
|
67
|
+
# @view_context ivar or method_missing delegation — phlex-rails' SGML#method_missing
|
|
68
|
+
# provides better error hints ("Try including Phlex::Rails::Helpers::LinkTo").
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Grsx
|
|
2
|
+
# Walks a compiled Phlex code string (or a Grsx AST) and collects every
|
|
3
|
+
# @ivar name referenced in the template.
|
|
4
|
+
#
|
|
5
|
+
# This powers the `props` DSL — it lets us tell the user which props are
|
|
6
|
+
# actually used in a template without requiring manual declaration.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# code = Grsx.compile(template)
|
|
10
|
+
# names = Grsx::PropInspector.scan_code(code)
|
|
11
|
+
# # => [:title, :body, :user]
|
|
12
|
+
#
|
|
13
|
+
# Or directly from a parse tree:
|
|
14
|
+
# root = Grsx::Parser.new(tokens).parse
|
|
15
|
+
# names = Grsx::PropInspector.scan_tree(root)
|
|
16
|
+
#
|
|
17
|
+
class PropInspector
|
|
18
|
+
# Regex that matches @ivar names in the already-compiled Phlex code string.
|
|
19
|
+
# Matches patterns like @title, @user_name, @items123
|
|
20
|
+
IVAR_PATTERN = /@([a-z_][a-zA-Z0-9_]*)/.freeze
|
|
21
|
+
|
|
22
|
+
# Scan compiled Phlex code (a Ruby string) for @ivar references
|
|
23
|
+
# and return them as an array of symbols.
|
|
24
|
+
def self.scan_code(code)
|
|
25
|
+
code.scan(IVAR_PATTERN).map { |m| m.first.to_sym }.uniq.sort
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Recursively walk a Grsx AST root node and collect @ivar identifiers
|
|
29
|
+
# from all expression nodes. Returns a sorted, unique array of symbols.
|
|
30
|
+
def self.scan_tree(root)
|
|
31
|
+
names = []
|
|
32
|
+
walk(root, names)
|
|
33
|
+
names.sort.uniq
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private_class_method def self.walk(node, names)
|
|
37
|
+
case node
|
|
38
|
+
when Nodes::Root
|
|
39
|
+
node.children.each { |c| walk(c, names) }
|
|
40
|
+
when Nodes::HTMLElement, Nodes::ComponentElement
|
|
41
|
+
node.members.each { |m| walk(m, names) }
|
|
42
|
+
(node.children || []).each { |c| walk(c, names) }
|
|
43
|
+
when Nodes::ExpressionGroup
|
|
44
|
+
node.members.each { |m| walk(m, names) }
|
|
45
|
+
when Nodes::Expression
|
|
46
|
+
node.content.scan(IVAR_PATTERN).each { |m| names << m.first.to_sym }
|
|
47
|
+
when Nodes::HTMLAttr, Nodes::ComponentProp
|
|
48
|
+
walk(node.value, names) if node.respond_to?(:value)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Grsx
|
|
2
|
+
module Rails
|
|
3
|
+
class Engine < ::Rails::Engine
|
|
4
|
+
# In development, hot-reload .rbx templates for PhlexComponent subclasses
|
|
5
|
+
# on every request via a lightweight Rack middleware.
|
|
6
|
+
initializer "grsx.phlex_reloader" do |app|
|
|
7
|
+
dev_mode = if app.config.respond_to?(:enable_reloading)
|
|
8
|
+
app.config.enable_reloading
|
|
9
|
+
else
|
|
10
|
+
!app.config.cache_classes
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
if dev_mode
|
|
14
|
+
require "grsx/rails/phlex_reloader"
|
|
15
|
+
app.middleware.use Grsx::Rails::PhlexReloader
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Grsx.configure do |config|
|
|
19
|
+
config.template_paths << ::Rails.root.join("app", "components")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Grsx
|
|
2
|
+
module Rails
|
|
3
|
+
# Rack middleware that hot-reloads .rbx templates for all known
|
|
4
|
+
# PhlexComponent subclasses on every request in development.
|
|
5
|
+
#
|
|
6
|
+
# Installed automatically by the Grsx Rails engine when
|
|
7
|
+
# config.cache_classes / config.enable_reloading indicates dev mode.
|
|
8
|
+
#
|
|
9
|
+
# You can also install it manually:
|
|
10
|
+
#
|
|
11
|
+
# # config/application.rb
|
|
12
|
+
# config.middleware.use Grsx::Rails::PhlexReloader
|
|
13
|
+
#
|
|
14
|
+
class PhlexReloader
|
|
15
|
+
def initialize(app)
|
|
16
|
+
@app = app
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(env)
|
|
20
|
+
Grsx::PhlexComponent.all_descendants.each(&:reload_rbx_template_if_changed)
|
|
21
|
+
@app.call(env)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/grsx/version.rb
ADDED
data/lib/grsx.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require "grsx/version"
|
|
2
|
+
require "active_support/inflector"
|
|
3
|
+
|
|
4
|
+
require "grsx/rails/engine" if defined?(::Rails)
|
|
5
|
+
|
|
6
|
+
module Grsx
|
|
7
|
+
autoload :Lexer, "grsx/lexer"
|
|
8
|
+
autoload :Parser, "grsx/parser"
|
|
9
|
+
autoload :Nodes, "grsx/nodes"
|
|
10
|
+
autoload :PhlexRuntime, "grsx/phlex_runtime"
|
|
11
|
+
autoload :PhlexCompiler, "grsx/phlex_compiler"
|
|
12
|
+
autoload :PhlexComponent, "grsx/phlex_component"
|
|
13
|
+
autoload :PropInspector, "grsx/prop_inspector"
|
|
14
|
+
autoload :Configuration, "grsx/configuration"
|
|
15
|
+
autoload :ComponentResolver, "grsx/component_resolver"
|
|
16
|
+
autoload :Template, "grsx/template"
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def configure
|
|
20
|
+
yield(configuration)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def configuration
|
|
24
|
+
@configuration ||= Configuration.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Compile a .rbx template to Phlex DSL Ruby code.
|
|
28
|
+
# Returns a string that can be class_eval'd inside a PhlexComponent.
|
|
29
|
+
def compile(template)
|
|
30
|
+
tokens = Lexer.new(template, configuration.element_resolver).tokenize
|
|
31
|
+
root = Parser.new(tokens).parse
|
|
32
|
+
PhlexCompiler.new(root).compile
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|