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 +7 -0
- data/CHANGELOG.md +11 -0
- data/Gemfile +10 -0
- data/README.md +31 -0
- data/Rakefile +12 -0
- data/lib/rbx/compiler.rb +164 -0
- data/lib/rbx/component.rb +59 -0
- data/lib/rbx/option_marshaller.rb +81 -0
- data/lib/rbx/parser.rb +204 -0
- data/lib/rbx/syntax_error.rb +45 -0
- data/lib/rbx/version.rb +6 -0
- data/lib/rbx.rb +169 -0
- data/rbxrb.gemspec +37 -0
- metadata +81 -0
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
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
data/lib/rbx/compiler.rb
ADDED
|
@@ -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
|
data/lib/rbx/version.rb
ADDED
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: []
|