rbxrb 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1ff8db77bcb7cdfbc84cd2f4ec4812c16c27eb8f59d096b32232ddbea098d66c
4
+ data.tar.gz: 5e6ccc51a4502d3eaa09c00a56c59381ca38d91d2baf66ab123a1f75fa626362
5
+ SHA512:
6
+ metadata.gz: 2ecd471a44e30f275b49f708c4a64904441463316f42476a758bee91b97c102f569628ad706a7d5392e22139245c65a96f272c2ec87b9511fde388c01bb22398
7
+ data.tar.gz: ae80fbb76b174081610fa71d582e9cbffe794ab395d001a1272313c7a99eaa61733dd794d30425b4e01d202164ef5b7542ce0bd7c332e657f612f99adce87a33
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-09-06
4
+
5
+ Initial release with support for
6
+
7
+ - Parsing templates
8
+ - Resolving tagnames to RBX::Component classes
9
+ - Compiling to HTML
10
+ - Auto-defining `render` on RBX::Components that will find the template, compile it
11
+ and define a ruby method on the component of the resulting ruby.
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ gem "irb"
8
+ gem "rake", "~> 13.0"
9
+ gem "rspec", "~> 3.0"
10
+ gem "rubocop", "~> 1.21"
data/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # RBX
2
+ A ruby templating language similar to JSX
3
+
4
+ ```ruby
5
+ class Layout
6
+ include RBX::Component
7
+ end
8
+
9
+ class Home
10
+ include RBX::Component
11
+
12
+ attr_reader :name
13
+
14
+ def initializer(name:)
15
+ @name = name
16
+ end
17
+ end
18
+
19
+ puts Home.new(name: "Bobby").render()
20
+
21
+ __END__
22
+ @@Layout
23
+ <html>
24
+ <body>{ yield }</body>
25
+ </html>
26
+
27
+ @@Home
28
+ <Layout>
29
+ <h1>Hello { name }</h1>
30
+ </Layout>
31
+ ```
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBX
4
+ # Compiler takes in the parsed AST and outputs generated code.
5
+ class Compiler
6
+ # Compile parsed nodes into ruby source code that will output html
7
+ def self.compile(nodes)
8
+ new.compile(nodes)
9
+ end
10
+
11
+ def compile(nodes) # :nodoc:
12
+ <<~RBX
13
+ buffer = String.new
14
+ #{nodes.map { |n| buf_out(compile_node(n)) }.join.rstrip}
15
+ buffer
16
+ RBX
17
+ end
18
+
19
+ private
20
+
21
+ def compile_node(node)
22
+ case node.kind
23
+ when :raw then node.content.strip.empty? ? "" : "'#{escape(node.content)}'"
24
+ when :html then compile_html(node).join("+")
25
+ when :component then compile_component(node)
26
+ when :expr_group then compile_text_expr_group(node)
27
+ else raise "unexpected node kind #{node.kind}"
28
+ end
29
+ end
30
+
31
+ def compile_text_expr_group(node)
32
+ return "(#{compile_expr_group(node)}).to_s" if contains_markup?(node)
33
+
34
+ "::RBX.escape((#{compile_expr_group(node)}))"
35
+ end
36
+
37
+ def contains_markup?(node)
38
+ node.content&.any? { |n| %i[html component].include?(n.kind) }
39
+ end
40
+
41
+ def compile_html(node)
42
+ parts = tag_open(node)
43
+ parts.concat(node.content.map(&method(:compile_node))) unless node.void || node.content.nil?
44
+ parts << "'</#{node.name}>'" unless node.void
45
+ compact(parts)
46
+ end
47
+
48
+ def tag_open(node)
49
+ buffer = "<#{node.name}"
50
+ parts = []
51
+ node.attributes&.each { |attr| tag_attribute(buffer, parts, attr) }
52
+ buffer << (node.void ? "/>" : ">")
53
+ parts << "'#{escape(buffer)}'"
54
+ parts
55
+ end
56
+
57
+ def tag_attribute(buffer, parts, attr)
58
+ return kwarg_attribute(buffer, parts, attr) if attr.kind == :expr_group
59
+
60
+ buffer << " #{attr.name}"
61
+ return if attr.content.nil?
62
+
63
+ buffer << "="
64
+ case attr.content.kind
65
+ when :raw then buffer << attr.content.content
66
+ when :expr_group then tag_attribute_expression(buffer, parts, attr.content)
67
+ else raise "unexpected attribute value kind #{attr.content.kind}"
68
+ end
69
+ end
70
+
71
+ def tag_attribute_expression(buffer, parts, expr_group)
72
+ buffer << '"'
73
+ flush_buffer(buffer, parts)
74
+ parts << "::RBX.escape((#{compile_attr_expr_group(expr_group)}))"
75
+ buffer << '"'
76
+ end
77
+
78
+ def kwarg_attribute(buffer, parts, expr_group)
79
+ flush_buffer(buffer, parts)
80
+ parts << "(::RBX.tag_kwargs(#{compile_attr_expr_group(expr_group)})).to_s"
81
+ end
82
+
83
+ def flush_buffer(buffer, parts)
84
+ parts << "'#{escape(buffer)}'" unless buffer.empty?
85
+ buffer.replace("")
86
+ end
87
+
88
+ def compile_component(node)
89
+ "::#{node.name.split(".").join("::")}.new(#{component_props(node)})#{component_block(node)}.render"
90
+ end
91
+
92
+ def component_props(node)
93
+ node.attributes&.map { |attr| component_prop(attr) }&.join(", ")
94
+ end
95
+
96
+ def component_prop(attr)
97
+ return "#{attr.name}: true" if attr.content.nil?
98
+
99
+ "#{attr.name}: #{component_prop_value(attr.content)}"
100
+ end
101
+
102
+ def component_prop_value(expr)
103
+ case expr.kind
104
+ when :raw then expr.content
105
+ when :expr_group then "(#{compile_attr_expr_group(expr)})"
106
+ else raise "unexpected component prop kind #{expr.kind}"
107
+ end
108
+ end
109
+
110
+ def compile_attr_expr_group(node)
111
+ node.content&.map do |n|
112
+ case n.kind
113
+ when :raw, :expression then n.content
114
+ else raise "unexpected component prop kind #{n.kind}"
115
+ end
116
+ end&.join
117
+ end
118
+
119
+ def compile_expr_group(node)
120
+ node.content&.map do |n|
121
+ case n.kind
122
+ when :raw, :expression then n.content
123
+ when :html then "(#{compile_html(n).join("+")})"
124
+ when :component then "(#{compile_component(n)})"
125
+ else raise "unexpected component prop kind #{n.kind}"
126
+ end
127
+ end&.join
128
+ end
129
+
130
+ def component_block(node)
131
+ return "" if node.content.nil? || node.content.empty?
132
+
133
+ <<~RBX.strip
134
+ .capture do |buffer|
135
+ #{node.content.map { |n| buf_out(compile_node(n)) }.join.rstrip}
136
+ end
137
+ RBX
138
+ end
139
+
140
+ def buf_out(content)
141
+ content.strip.empty? ? "" : %(buffer << #{content}\n)
142
+ end
143
+
144
+ def escape(str)
145
+ str.gsub("\\") { "\\\\" }.gsub("'") { "\\'" }
146
+ end
147
+
148
+ def compact(parts)
149
+ parts&.each_with_object([]) do |item, compacted|
150
+ next if item.empty?
151
+
152
+ if quoted_string?(item) && quoted_string?(compacted.last)
153
+ compacted[-1] = compacted.last.delete_suffix("'") + item.delete_prefix("'")
154
+ else
155
+ compacted.push(item)
156
+ end
157
+ end
158
+ end
159
+
160
+ def quoted_string?(item)
161
+ item =~ /^'.*'$/
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBX
4
+ # Component is a mixin that lazily resolves and compiles a template into a
5
+ # `_render_template` instance method on the first render, then calls that
6
+ # compiled method directly on every render after that. It also provides
7
+ # the supporting capture/option marshalling behavior compiled templates
8
+ # rely on.
9
+ module Component
10
+ def self.included(base) # :nodoc:
11
+ base.extend(ClassMethods)
12
+ end
13
+
14
+ # ClassMethods is extended onto any class that includes Component. It
15
+ # handles template resolution and compilation.
16
+ module ClassMethods
17
+ # Set the template for this class as a provided string. This method uses
18
+ # caller location to be able to provide better error messages.
19
+ def template_source(tmpl)
20
+ compile!(caller_locations(1, 1).first.absolute_path, tmpl)
21
+ end
22
+
23
+ # Set the template for this class as the contents of a separate file
24
+ def template_file(filepath)
25
+ loc, tmpl = RBX.extract_template(filepath, self, inline: false)
26
+ compile!(loc, tmpl)
27
+ end
28
+
29
+ protected
30
+
31
+ def compile!(loc, tmpl) # :nodoc:
32
+ return if loc.nil? || tmpl.empty?
33
+
34
+ class_eval(<<~RENDER, __FILE__, __LINE__ + 1)
35
+ def _render_template
36
+ #{RBX.compile(loc, tmpl)} # compiled template body
37
+ end
38
+ RENDER
39
+ end
40
+ end
41
+
42
+ # render will lazily compile the template for this class and then render it using
43
+ # the class as context as the compilation will define a _render_template method
44
+ # on the instance.
45
+ def render
46
+ klass = self.class
47
+ klass.send(:compile!, *RBX.resolve_template(klass)) unless klass.respond_to?(:_render_template)
48
+
49
+ _render_template { RBX.safe(@child_buffer.nil? ? "" : @child_buffer) }
50
+ end
51
+
52
+ # public method to capture child content but should not be considered public interface.
53
+ def capture(&) # :nodoc:
54
+ @child_buffer = String.new
55
+ yield(@child_buffer)
56
+ self
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rack"
5
+
6
+ module RBX
7
+ # OptionMarshaller is a class that is used for serializing a hash into
8
+ # html tag attributes.
9
+ class OptionMarshaller
10
+ BOOLEAN_ATTRIBUTES = %w[allowfullscreen allowpaymentrequest async autofocus
11
+ autoplay checked compact controls declare default
12
+ defaultchecked defaultmuted defaultselected defer
13
+ disabled enabled formnovalidate hidden indeterminate
14
+ inert ismap itemscope loop multiple muted nohref
15
+ nomodule noresize noshade novalidate nowrap open
16
+ pauseonexit playsinline readonly required reversed
17
+ scoped seamless selected sortable truespeed
18
+ typemustmatch visible].to_set
19
+
20
+ BOOLEAN_ATTRIBUTES.merge(BOOLEAN_ATTRIBUTES.map(&:to_sym))
21
+ BOOLEAN_ATTRIBUTES.freeze
22
+
23
+ private_constant :BOOLEAN_ATTRIBUTES
24
+
25
+ class << self
26
+ ##
27
+ # Marshal a hash into html tag attributes. If the key is aria, data, or class
28
+ # it will also spread that data creatively like `data-key="true"` from `{data: {key: true}}`
29
+ # and for class it will only include the class name if the value is truthy.
30
+ def tag_kwargs(options)
31
+ options.reject { |k, _v| k.empty? }.map do |key, value|
32
+ case key.to_s
33
+ when "aria" then aria_attribute(value)
34
+ when "data" then data_attribute(value)
35
+ when "class" then class_attribute(value)
36
+ when "hx" then hx_attribute(value)
37
+ else boolean_or_option(key, value)
38
+ end
39
+ end.flatten.compact.join(" ")
40
+ end
41
+
42
+ private
43
+
44
+ def boolean_or_option(key, value)
45
+ BOOLEAN_ATTRIBUTES.include?(key) ? (key if value) : tag_option(key, value)
46
+ end
47
+
48
+ def aria_attribute(value)
49
+ value.map { |k, v| %(aria-#{k}="#{Rack::Utils.escape_html(v.to_s)}") }
50
+ end
51
+
52
+ def data_attribute(value)
53
+ value.map do |k, v|
54
+ tag_option("data-#{k}", v.is_a?(String) || v.is_a?(Symbol) ? v : JSON.dump(v))
55
+ end
56
+ end
57
+
58
+ def hx_attribute(value)
59
+ value.map { |k, v| tag_option("hx-#{k}", v.to_s) }
60
+ end
61
+
62
+ def class_attribute(value)
63
+ classes = if value.is_a?(Hash)
64
+ value.each_with_object([]) { |(key, value), final| final << key if value }.join(" ")
65
+ else
66
+ value.is_a?(Array) ? value.join(" ") : value.to_s
67
+ end
68
+ %(class="#{Rack::Utils.escape_html(classes)}")
69
+ end
70
+
71
+ def tag_option(key, value)
72
+ value = case value
73
+ when Array then value.join(" ")
74
+ when Hash then value.map { |k, v| "#{k}=#{v}" }.join(" ")
75
+ else value.to_s
76
+ end
77
+ %(#{key.to_s.tr("_", "-")}="#{Rack::Utils.escape_html(value)}")
78
+ end
79
+ end
80
+ end
81
+ end
data/lib/rbx/parser.rb ADDED
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBX
4
+ # Parser breaks down templates into a tree like data structure
5
+ class Parser
6
+ KNOWN_HTML_ELEMENTS = %w[
7
+ a abbr acronym address animate animateMotion animateTransform applet area article aside audio b base basefont
8
+ bdi bdo bgsound big blink blockquote body br button canvas caption center circle cite clipPath code col colgroup
9
+ color-profile command content data datalist dd defs del desc details dfn dialog dir discard div dl dt element
10
+ ellipse em embed feBlend feColorMatrix feComponentTransfer feComposite feConvolveMatrix feDiffuseLighting
11
+ feDisplacementMap feDistantLight feDropShadow feFlood feFuncA feFuncB feFuncG feFuncR feGaussianBlur feImage
12
+ feMerge feMergeNode feMorphology feOffset fePointLight feSpecularLighting feSpotLight feTile feTurbulence
13
+ fieldset figcaption figure filter font footer foreignObject form frame frameset g h1 h2 h3 h4 h5 h6 hatch
14
+ hatchpath head header hgroup hr html i iframe image img input ins isindex kbd keygen label legend li line
15
+ linearGradient link listing main map mark marker marquee mask menu menuitem mesh meshgradient meshpatch meshrow
16
+ meta metadata meter mpath multicol nav nextid nobr noembed noframes noscript object ol optgroup option output p
17
+ param path pattern picture plaintext polygon polyline pre progress q radialGradient rb rect rp rt rtc ruby s
18
+ samp script section select set shadow slot small solidcolor source spacer span stop strike strong style sub
19
+ summary sup svg switch symbol table tbody td template text textarea textPath tfoot th thead time title tr track
20
+ tspan tt u ul unknown use var video view wbr xmp
21
+ ].to_set
22
+
23
+ HTML_VOID_ELEMENTS = %w[area base br col embed hr img input link meta source track wbr].freeze
24
+ DECLR_OR_COMMENT = /\s*<![^>]*>/
25
+ TAG_START = %r{\s*<(?!/)}
26
+ TAG_END = %r{\s*/?>}
27
+ EXPR_START = /\s*{/
28
+ QUOTED_STRING = /["'](?<str>(?:[^"'\\]|\\.)*)["']/
29
+ QUOTES = /["']/
30
+ QUOTED_EXPRESSION_STRING = /(?<q>["'])(?:\\.|(?!\k<q>).)*\k<q>/m
31
+ PLAIN_TEXT = /(?<text>(?:[^<{\\]|\\.)*)/
32
+ TAGNAME = /[A-Za-z0-9\-_.]+/
33
+ DO_BLOCK_PREFIX = /do\s+(\|[^|]+\|)?/
34
+ BLOCK_PREFIX = /{\s*(\|[^|]+\|)?/
35
+ AND = /&&/
36
+ OR = /\|\|/
37
+ TERNARY = /[?:]/
38
+ TAG_PREFIX = Regexp.union(DO_BLOCK_PREFIX, BLOCK_PREFIX, AND, OR, TERNARY)
39
+ NESTED_TAG_PREFIX = /(\s+#{TAG_PREFIX.source}\s*\z|\A\s*\z)/
40
+ WORD = /\w+/
41
+
42
+ private_constant :KNOWN_HTML_ELEMENTS, :HTML_VOID_ELEMENTS, :DECLR_OR_COMMENT,
43
+ :TAG_START, :TAG_END, :EXPR_START, :QUOTED_STRING, :QUOTES,
44
+ :QUOTED_EXPRESSION_STRING, :PLAIN_TEXT, :TAGNAME, :DO_BLOCK_PREFIX,
45
+ :BLOCK_PREFIX, :AND, :OR, :TERNARY, :TAG_PREFIX, :NESTED_TAG_PREFIX, :WORD
46
+
47
+ attr_reader :filename, :template, :scanner # :nodoc:
48
+
49
+ # Is a struct for a single node in an AST type structure returned from the parser.
50
+ # This struct consists of:
51
+ #
52
+ # - `kind`: raw, attribute, expression, expr_group, html, component
53
+ # - `name`: If it is a component or html element this will be the tag name and
54
+ # the name of an element attribute if the node is an attribute.
55
+ # - `attributes`: For tag, or components these are the attributes and their values.
56
+ # - `content`: For tag, or components this will be any nested child nodes, for
57
+ # raw or expressions, this will be a string of raw output.
58
+ # - `void`: marks if an element has nested content or expects nested content.
59
+ Node = Struct.new(:kind, :name, :attributes, :content, :void)
60
+
61
+ # Reads a template, lexes tokens, and builds an AST from the tokens.
62
+ def self.parse(filename, template)
63
+ new(filename, template).parse
64
+ end
65
+
66
+ def initialize(filename, template) # :nodoc:
67
+ @filename = filename
68
+ @template = template
69
+ @scanner = StringScanner.new(template)
70
+ end
71
+
72
+ def parse # :nodoc:
73
+ parse_children
74
+ end
75
+
76
+ private
77
+
78
+ def parse_children
79
+ children = []
80
+ children << parse_child until scanner.eos? || scanner.check(%r{</})
81
+ children.empty? ? nil : children
82
+ end
83
+
84
+ def parse_child
85
+ if scanner.scan(DECLR_OR_COMMENT) then Node.new(kind: :raw, content: scanner.matched.gsub("'", "\\\\'"))
86
+ elsif scanner.scan(TAG_START) then parse_tag
87
+ elsif scanner.scan(EXPR_START) then parse_expression
88
+ elsif scanner.scan(PLAIN_TEXT) then Node.new(kind: :raw, content: scanner[:text])
89
+ else raise SyntaxError.new(self, "unexpected element")
90
+ end
91
+ end
92
+
93
+ def parse_tag
94
+ tagname = scanner.scan(TAGNAME) # tagname
95
+ attributes = parse_tag_attributes
96
+ raise SyntaxError.new(self, "unclosed tag <#{tagname} found") unless scanner.scan(TAG_END)
97
+
98
+ is_void = scanner.matched.strip == "/>" || HTML_VOID_ELEMENTS.include?(tagname)
99
+ children = is_void ? nil : parse_tag_contents(tagname, is_void)
100
+ Node.new(kind: resolve_kind(tagname), name: tagname, void: is_void, attributes: attributes, content: children)
101
+ end
102
+
103
+ def parse_tag_contents(tagname, is_void)
104
+ return consume_script_tag_raw(tagname) if tagname == "script" && !is_void
105
+
106
+ children = parse_children
107
+ if !is_void && !scanner.scan(%r{\s*</#{tagname}>})
108
+ raise SyntaxError.new(self, "Closing tag for non-void <#{tagname}> not found")
109
+ end
110
+
111
+ children
112
+ end
113
+
114
+ # We consume script tag contents simply as a raw node because we cannot interpolate
115
+ # anything in javascript. We cannot tell what {} are javascript and which are
116
+ # interpolation so it is safer to just leave script contents alone.
117
+ def consume_script_tag_raw(tagname)
118
+ warn "WARN found a <script> tag in #{filename}, be aware contents are consumed raw and will not be interpolated."
119
+
120
+ scanner.scan(%r{(?<str>.*)</#{tagname}>})
121
+ val = scanner[:str]
122
+ raise SyntaxError.new(self, "unterminated tag #{tagname}") if val.nil?
123
+
124
+ [Node.new(kind: :raw, content: val)]
125
+ end
126
+
127
+ def resolve_kind(tagname)
128
+ return :html if KNOWN_HTML_ELEMENTS.include?(tagname)
129
+
130
+ klass_name = "::#{tagname.split(".").join("::")}"
131
+ klass = Object.const_get(klass_name)
132
+ # TODO: allow duck typing, i.e responds_to
133
+ !klass.nil? && klass.include?(RBX::Component) ? :component : :html
134
+ end
135
+
136
+ def parse_tag_attributes
137
+ attrs = []
138
+ while scanner.check(/\s+[A-Za-z0-9\-_.:]+/) || scanner.check(EXPR_START)
139
+ attrs << (scanner.scan(EXPR_START) ? parse_expression : parse_tag_attribute)
140
+ end
141
+ attrs.empty? ? nil : attrs
142
+ end
143
+
144
+ def parse_tag_attribute
145
+ name = scanner.scan(/\s+[A-Za-z0-9\-_.:]+/).strip
146
+ value = parse_tag_attribute_value if scanner.scan(/\s*=\s*/)
147
+ Node.new(kind: :attribute, name: name, content: value)
148
+ end
149
+
150
+ def parse_tag_attribute_value
151
+ if scanner.check(QUOTES) then Node.new(kind: :raw, content: chomp_quoted)
152
+ elsif scanner.scan(WORD) then Node.new(kind: :raw, content: scanner.matched)
153
+ elsif scanner.scan(EXPR_START) then parse_expression
154
+ else raise SyntaxError.new(self, "unexpected tag attribute formatting.")
155
+ end
156
+ end
157
+
158
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
159
+
160
+ def parse_expression
161
+ exprs = []
162
+
163
+ depth = 0
164
+ curr_expr = String.new
165
+ until scanner.eos?
166
+ if scanner.scan(/}/)
167
+ break if depth.zero?
168
+
169
+ depth -= 1
170
+ curr_expr << "}"
171
+ elsif scanner.scan(QUOTED_EXPRESSION_STRING)
172
+ curr_expr << scanner.matched
173
+ elsif scanner.scan(TAG_START)
174
+ # try to work out if we found a tag or a lessthan
175
+ if curr_expr =~ NESTED_TAG_PREFIX
176
+ exprs << Node.new(kind: :expression, content: curr_expr) unless curr_expr.empty?
177
+ curr_expr = String.new
178
+ exprs << parse_tag
179
+ else
180
+ curr_expr << scanner.matched
181
+ end
182
+ else
183
+ text = scanner.scan(/([^<}'"]|<\/)+/) || scanner.getch
184
+ depth += text.count("{")
185
+ curr_expr << text
186
+ end
187
+ end
188
+
189
+ exprs << Node.new(kind: :expression, content: curr_expr) unless curr_expr.empty?
190
+
191
+ Node.new(kind: :expr_group, content: exprs)
192
+ end
193
+
194
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
195
+
196
+ def chomp_quoted
197
+ scanner.scan(QUOTED_STRING)
198
+ val = scanner[:str]
199
+ raise SyntaxError.new(self, "unterminated string") if val.nil?
200
+
201
+ %("#{val}")
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBX
4
+ # SyntaxError is a error with a pinpoint of where in the template the issue lies.
5
+ class SyntaxError < StandardError
6
+ # Create a new syntax error with the parser, to locate where the syntax error
7
+ # is located.
8
+ def initialize(parser, message)
9
+ @parser = parser
10
+ super("#{@parser.filename}:#{line}:#{col} #{message}\n#{excerpt}")
11
+ end
12
+
13
+ private
14
+
15
+ def excerpt
16
+ (excerpt_start..excerpt_end).map do |i|
17
+ "#{i + 1 == line ? "->" : " "} #{i + 1}: #{lines[i]}"
18
+ end.join("\n")
19
+ end
20
+
21
+ def lines
22
+ @lines ||= @parser.template.split(/\R/, -1)
23
+ end
24
+
25
+ def excerpt_start
26
+ [line - 4, 0].max
27
+ end
28
+
29
+ def excerpt_end
30
+ [line - 5, lines.length - 1].min
31
+ end
32
+
33
+ def line
34
+ @line ||= line_info.size
35
+ end
36
+
37
+ def col
38
+ @col ||= line_info.last.length + 1
39
+ end
40
+
41
+ def line_info
42
+ @line_info ||= @parser.template[0..@parser.scanner.pos].split(/\R/, -1)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBX
4
+ # Version number for RBX
5
+ VERSION = "0.1.0"
6
+ end
data/lib/rbx.rb ADDED
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ # RBX is a module for handling enriched html parsing in a same manner that jsx does
6
+ # allowing for class resolution for tag names and dynamic content taken from those
7
+ # ruby classes.
8
+ #
9
+ # Example:
10
+ #
11
+ # ```ruby
12
+ # module Todos
13
+ # class List
14
+ # include RBX::Component
15
+ #
16
+ # attr_reader :todos
17
+ #
18
+ # def initialize(todos:)
19
+ # super()
20
+ # @todos = todos
21
+ # end
22
+ # end
23
+ #
24
+ # class Todo
25
+ # include RBX::Component
26
+ # attr_reader :todo
27
+ #
28
+ # def initialize(todo:)
29
+ # super()
30
+ # @todo = todo
31
+ # end
32
+ # end
33
+ # end
34
+ #
35
+ # view = Todos::List.new(todos: [{"text" => "work"}])
36
+ # puts view.render # => <ul><li>work</li> </ul>
37
+ #
38
+ # __END__
39
+ # @@Todos.List
40
+ # <ul>{todos.map { |todo| <Todos.Todo todo={todo} /> }.join}</ul>
41
+ #
42
+ # @@Todos.Todo
43
+ # <li>{ todo["text"] }</li>
44
+ # ```
45
+ #
46
+ # Component templates can be defined in several ways.
47
+ #
48
+ # - With the entirety of the `__END__` section if it does not contain any labels.
49
+ # - Several templates defined for several classes in the same file (like the example above).
50
+ # To do this the templates need a `@@<klass-name>` label to identify them.
51
+ # - Use the component class method `template_file` to define where to look for the
52
+ # template file.
53
+ # - Use the component class method `template_source` to provide a raw string as a
54
+ # template for the component.
55
+ #
56
+ module RBX
57
+ autoload :Compiler, "rbx/compiler"
58
+ autoload :Parser, "rbx/parser"
59
+ autoload :SyntaxError, "rbx/syntax_error"
60
+ autoload :Component, "rbx/component"
61
+ autoload :OptionMarshaller, "rbx/option_marshaller"
62
+ autoload :Version, "rbx/version"
63
+
64
+ # SafeString is a string wrapper that marks a string as having been escaped and
65
+ # is safe for putting on a webpage.
66
+ class SafeString < String; end
67
+
68
+ @template_cache = {}
69
+
70
+ class << self
71
+ # parse and then compile the template which will return a string of ruby to
72
+ # be evaluated. This can then be passed to `instance_eval` to make it execute
73
+ # in context of your class. Include `RBX::Component` in your class to do this
74
+ # for you. It will lazily compile the template and cache it afterwards.
75
+ def compile(template_name, template)
76
+ Compiler.compile(Parser.parse(template_name, template))
77
+ end
78
+
79
+ # escape any non-safe text for html. This is used to ensure that text that
80
+ # you are using in your templates will not cause security issues. This will
81
+ # be outputted by the compiler so it is here also for easy access by the compiled
82
+ # ruby.
83
+ def escape(value)
84
+ value.is_a?(SafeString) ? value.to_s : ::Rack::Utils.escape_html(value.to_s)
85
+ end
86
+
87
+ # mark a string as safe, not needing any further escaping. This can be done to
88
+ # skip escaping if you're sure it needs to be raw.
89
+ def safe(value)
90
+ SafeString.new(value.to_s)
91
+ end
92
+
93
+ # marshal a hash into html tag attributes. See OptionMarshaller.
94
+ def tag_kwargs(options)
95
+ " #{OptionMarshaller.tag_kwargs(options)}"
96
+ end
97
+
98
+ # resolve an inline template for a klass. It will look at the klass's source
99
+ # location, and check if there is an `__END__` section on the file. If there
100
+ # is, it will extract that template data.
101
+ #
102
+ # A template can be the whole `__END__` section if there are no labels found
103
+ # or several templates can be defined in the `__END__` section by labeling them
104
+ # with an `@@<name>` label on the line before the template. See the example above.
105
+ def resolve_template(klass)
106
+ location = Object.const_source_location(klass.name).first
107
+ extract_template(location, klass, inline: true)
108
+ end
109
+
110
+ # depending on how the template is defined, extract_template will look in the
111
+ # `__END__` section, determine if there are several templates, and pick out
112
+ # the template for the specific class passed as a parameter. It will return
113
+ # an empty string if no content was found.
114
+ #
115
+ # This will also ensure that templates were not reused just incase labels were
116
+ # not added.
117
+ def extract_template(filepath, klass, inline: true)
118
+ name = klass.name.split("::").last
119
+ fullname = template_name(filepath, name)
120
+ parse_templates(filepath, name, inline) unless @template_cache[fullname]
121
+ tmpl = @template_cache.fetch(fullname, nil)
122
+ validate_uniq_templates(filepath, tmpl[:content]) if tmpl
123
+
124
+ [fullname, tmpl&.dig(:content) || ""]
125
+ end
126
+
127
+ private
128
+
129
+ def validate_uniq_templates(filepath, content)
130
+ found = @template_cache.values.select { |tmpl| tmpl[:filepath] == filepath && tmpl[:content] == content }
131
+ return unless found.count > 1
132
+
133
+ warn "[RBX WARNING] Possible reuse of template #{template_name(found[:filepath], found[:name])}"
134
+ end
135
+
136
+ def template_name(filepath, name)
137
+ "#{filepath}@@#{name}"
138
+ end
139
+
140
+ def parse_templates(filepath, name, inline)
141
+ data = read_file(filepath, inline)
142
+ return if data.nil?
143
+
144
+ cache_template(filepath, name, data)
145
+ return unless data.include?("@@")
146
+
147
+ data.strip.split(/^(@@\s*.*\S)\s*$/)[1..].each_slice(2) do |(section_name, tmpl)|
148
+ section_name = section_name.delete_prefix("@@").strip
149
+ cache_template(filepath, section_name, tmpl)
150
+ end
151
+ end
152
+
153
+ def cache_template(filepath, name, content)
154
+ @template_cache[template_name(filepath, name)] = {
155
+ filepath: filepath,
156
+ name: name,
157
+ content: content
158
+ }
159
+ end
160
+
161
+ def read_file(filepath, inline)
162
+ data = File.read(filepath)
163
+ return if inline && !data.include?("__END__")
164
+
165
+ data = data.split(/^__END__$/, 2)[1] if data.include?("__END__")
166
+ data
167
+ end
168
+ end
169
+ end
data/rbxrb.gemspec ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/rbx/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "rbxrb"
7
+ spec.version = RBX::VERSION
8
+ spec.authors = ["Tim Anema"]
9
+ spec.email = ["timanema@gmail.com"]
10
+ spec.summary = "A ruby templating language similar to JSX."
11
+ spec.description = <<~DOC
12
+ A ruby templating language similar to JSX that compiles to a ruby function.
13
+ It allows for defining that function on a class that can then define variables
14
+ and methods that can be used in the template.
15
+ DOC
16
+ spec.homepage = "https://github.com/tanema/nextrb"
17
+ spec.license = "MIT"
18
+ spec.required_ruby_version = ">= 3.1"
19
+ spec.require_paths = ["lib"]
20
+ spec.metadata = {
21
+ "source_code_uri" => "https://github.com/tanema/nextrb",
22
+ "changelog_uri" => "https://github.com/tanema/nextrb/blob/main/rbx/CHANGELOG.md",
23
+ "homepage_uri" => spec.homepage,
24
+ "bug_tracker_uri" => "https://github.com/tanema/nextrb/issues",
25
+ "documentation_uri" => "https://github.com/tanema/nextrb",
26
+ "rubygems_mfa_required" => "true"
27
+ }
28
+ spec.files = Dir["lib/**/*"] + [
29
+ "README.md",
30
+ "CHANGELOG.md",
31
+ "Gemfile",
32
+ "Rakefile",
33
+ "rbxrb.gemspec"
34
+ ]
35
+
36
+ spec.add_dependency "rack", ">= 3.0.0", "< 4"
37
+ end
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rbxrb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tim Anema
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 3.0.0
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '4'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 3.0.0
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '4'
32
+ description: |
33
+ A ruby templating language similar to JSX that compiles to a ruby function.
34
+ It allows for defining that function on a class that can then define variables
35
+ and methods that can be used in the template.
36
+ email:
37
+ - timanema@gmail.com
38
+ executables: []
39
+ extensions: []
40
+ extra_rdoc_files: []
41
+ files:
42
+ - CHANGELOG.md
43
+ - Gemfile
44
+ - README.md
45
+ - Rakefile
46
+ - lib/rbx.rb
47
+ - lib/rbx/compiler.rb
48
+ - lib/rbx/component.rb
49
+ - lib/rbx/option_marshaller.rb
50
+ - lib/rbx/parser.rb
51
+ - lib/rbx/syntax_error.rb
52
+ - lib/rbx/version.rb
53
+ - rbxrb.gemspec
54
+ homepage: https://github.com/tanema/nextrb
55
+ licenses:
56
+ - MIT
57
+ metadata:
58
+ source_code_uri: https://github.com/tanema/nextrb
59
+ changelog_uri: https://github.com/tanema/nextrb/blob/main/rbx/CHANGELOG.md
60
+ homepage_uri: https://github.com/tanema/nextrb
61
+ bug_tracker_uri: https://github.com/tanema/nextrb/issues
62
+ documentation_uri: https://github.com/tanema/nextrb
63
+ rubygems_mfa_required: 'true'
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '3.1'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 3.6.9
79
+ specification_version: 4
80
+ summary: A ruby templating language similar to JSX.
81
+ test_files: []