span_view 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: a817462b757b21ce691f3c8c6a69db01a42ac1dc1a441fbca983c16621fbc76f
4
+ data.tar.gz: bcf9b53f48db929ee28596a65de0512adcad12cc920ead5714191e894bc1dcc6
5
+ SHA512:
6
+ metadata.gz: 144d54323855f13488f79ca44e41274eb814ff4ee8fa50506909f599b267611cbcd61951ebe281ee9d3d35b962cddfea3817a4ded0cce47c6a8a5fde834796df
7
+ data.tar.gz: f499383736eab6bc7967c3b9415c5c353fd956b841f63256086f19cf96b303f62b3185cba0da9928e79c71e433f765ea0b821e420eb60326fc3881b4a284bd46
data/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # SpanView
2
+
3
+ TODO: Delete this and the text below, and describe your gem
4
+
5
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/span_view`. To experiment with that code, run `bin/console` for an interactive prompt.
6
+
7
+ ## Installation
8
+
9
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ ```bash
14
+ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
15
+ ```
16
+
17
+ If bundler is not being used to manage dependencies, install the gem by executing:
18
+
19
+ ```bash
20
+ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/fapapa/league.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ Bundler::GemHelper.tag_prefix = "span_view-"
5
+ require "minitest/test_task"
6
+
7
+ Minitest::TestTask.create
8
+
9
+ require "standard/rake"
10
+
11
+ task default: %i[test standard]
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpanView
4
+ class Error < StandardError; end
5
+
6
+ class UnknownComponentError < Error; end
7
+
8
+ class ParseError < Error; end
9
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parslet"
4
+ require_relative "errors"
5
+ require_relative "span/grammar"
6
+ require_relative "span/compiler"
7
+
8
+ module SpanView
9
+ # Public interface: `.span` source string in → compiled ERB string out.
10
+ # Pure function, no Rails coupling. Parses with the Span::Grammar, compiles with
11
+ # the Span::Compiler transform.
12
+ module Parser
13
+ def self.call(source)
14
+ Span::Compiler.new.apply(Span::Grammar.new.parse(source))
15
+ rescue Parslet::ParseFailed => e
16
+ raise ParseError, "Invalid .span template: #{deepest_failure_line(e)}"
17
+ end
18
+
19
+ # The root of `parse_failure_cause.ascii_tree` reports where the top-level
20
+ # repetition gave up (often "line 1 char 1"); the line that progressed
21
+ # furthest into the input is the one that names the actual problem.
22
+ def self.deepest_failure_line(error)
23
+ error.parse_failure_cause.ascii_tree.lines
24
+ .map { |line| line.strip.sub(/\A[|`\s-]+/, "") }
25
+ .reverse # ties resolve to the deepest (most specific) tree node
26
+ .max_by { |line| line.match(/line (\d+) char (\d+)\.\z/) { |m| [m[1].to_i, m[2].to_i] } || [0, 0] }
27
+ end
28
+ private_class_method :deepest_failure_line
29
+ end
30
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parslet"
4
+ require_relative "../errors"
5
+
6
+ module SpanView
7
+ module Span
8
+ # Compiles an Span::Grammar parse tree into an ERB string.
9
+ # `tokens:` is ALWAYS emitted (even empty → `%i[]`); tag pairs emit `do |c|`
10
+ # (the block param is the slot receiver Story 4.4 will use). Self-closing tags
11
+ # emit no block.
12
+ class Compiler < Parslet::Transform
13
+ # Marks a kv-pair kwarg so render_call can partition it away from plain
14
+ # token strings; `code` is the already-formatted Ruby value (quoted or verbatim).
15
+ KvPair = Struct.new(:key, :code)
16
+
17
+ rule(text: simple(:t)) { t.to_s }
18
+ rule(token: simple(:t)) { t.to_s }
19
+ rule(source: sequence(:nodes)) { nodes.join }
20
+
21
+ rule(grouped: {prefix: simple(:prefix), inner: subtree(:inner)}) do
22
+ Array(inner).map { |tok| "#{prefix}:#{tok}" }
23
+ end
24
+
25
+ rule(kv: {key: simple(:key), dq: subtree(:val)}) do
26
+ content = val.is_a?(Array) ? "" : val.to_s
27
+ KvPair.new(key.to_s, "\"#{content}\"")
28
+ end
29
+ rule(kv: {key: simple(:key), bare: simple(:val)}) { KvPair.new(key.to_s, val.to_s) }
30
+
31
+ rule(self_close: {name: simple(:name), args: subtree(:args)}) do
32
+ "<%= render(#{Compiler.render_call(name, args)}) %>"
33
+ end
34
+
35
+ rule(tag_pair: {name: simple(:name), args: subtree(:args), source: subtree(:children), close: simple(:close)}) do
36
+ unless name.to_s == close.to_s
37
+ raise SpanView::ParseError, "Mismatched tags: <#{name}> closed by </#{close}>"
38
+ end
39
+ "<%= render(#{Compiler.render_call(name, args)}) do |c| %>#{Array(children).join}<% end %>"
40
+ end
41
+
42
+ rule(slot: {sname: simple(:sname), source: subtree(:children), close: simple(:close)}) do
43
+ unless sname.to_s == close.to_s
44
+ raise SpanView::ParseError, "Mismatched slot tags: <:#{sname}> closed by </:#{close}>"
45
+ end
46
+ "<% c.with_#{sname} do %>#{Array(children).join}<% end %>"
47
+ end
48
+
49
+ def self.render_call(name, args)
50
+ const = "#{name}Component"
51
+ unless Object.const_defined?(const)
52
+ raise SpanView::UnknownComponentError, "Unknown component <#{name}>: no #{const} class is defined"
53
+ end
54
+
55
+ token_strings, kv_pairs = [], []
56
+ Array(args).each do |item|
57
+ case item
58
+ when KvPair
59
+ unless item.key.match?(/\A[a-z_][a-zA-Z0-9_]*\z/)
60
+ raise SpanView::ParseError, "Invalid attribute key '#{item.key}': not a valid Ruby keyword argument"
61
+ end
62
+ kv_pairs << "#{item.key}: #{item.code}"
63
+ when Array then token_strings.concat(item)
64
+ else token_strings << item
65
+ end
66
+ end
67
+ kwargs = kv_pairs.empty? ? "" : ", #{kv_pairs.join(", ")}"
68
+ "#{const}.new(tokens: %i[#{token_strings.join(" ")}]#{kwargs})"
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parslet"
4
+
5
+ module SpanView
6
+ module Span
7
+ # PEG grammar for the `.span` template language (structure only; codegen lives
8
+ # in Span::Compiler).
9
+ class Grammar < Parslet::Parser
10
+ root(:source)
11
+
12
+ rule(:source) { (erb | component | slot | passthrough).repeat.as(:source) }
13
+
14
+ # ERB tags are atomic: the whole `<% ... %>` / `<%= ... %>` run is one
15
+ # opaque verbatim unit, terminated by the first subsequent `%>`. The
16
+ # parser never looks inside — no interpretation of Ruby content (FR-25/26).
17
+ rule(:erb) { (erb_edge >> (str("%>").absent? >> any).repeat >> str("%>")).as(:text) }
18
+
19
+ # Verbatim text: plain HTML, lowercase tags — anything not opening/closing a component, slot, or ERB tag.
20
+ rule(:passthrough) { (node_edge.absent? >> any).repeat(1).as(:text) }
21
+ rule(:node_edge) { component_edge | slot_edge | erb_edge }
22
+ rule(:component_edge) { (str("<") >> match["A-Z"]) | (str("</") >> sp? >> match["A-Z"]) }
23
+ # Only a lowercase-initial name opens a slot, so `<:` before anything else
24
+ # (uppercase, space, punctuation) falls through to passthrough as verbatim
25
+ # text instead of hard-failing the whole parse.
26
+ rule(:slot_edge) { (str("<:") | str("</:")) >> match["a-z"] }
27
+ # Stops passthrough at `<%` so the `erb` rule claims the tag whole —
28
+ # without this, passthrough would consume into an ERB tag and could
29
+ # hard-stop at a `<`+uppercase inside it (e.g. `<%= "<Box>" %>`).
30
+ # `<%%` (ERB's literal-escape) is excluded: it is not an ERB opener, so
31
+ # neither the erb rule claims it nor does passthrough stop at it — the
32
+ # sequence falls through to passthrough and emits verbatim.
33
+ rule(:erb_edge) { str("<%") >> str("%").absent? }
34
+
35
+ rule(:component) { self_close | tag_pair }
36
+ rule(:self_close) { (str("<") >> cname >> args >> sp? >> str("/>")).as(:self_close) }
37
+ rule(:tag_pair) do
38
+ (str("<") >> cname >> args >> sp? >> str(">") >>
39
+ source >> str("</") >> sp? >> cname_close.as(:close) >> sp? >> str(">")).as(:tag_pair)
40
+ end
41
+
42
+ rule(:cname) { (match["A-Z"] >> match["A-Za-z"].repeat).as(:name) }
43
+ rule(:cname_close) { match["A-Z"] >> match["A-Za-z"].repeat }
44
+
45
+ rule(:slot) do
46
+ (str("<:") >> sname >> sp? >> str(">") >>
47
+ source >> str("</:") >> sp? >> sname_close.as(:close) >> sp? >> str(">")).as(:slot)
48
+ end
49
+ rule(:sname) { (match["a-z"] >> match["a-zA-Z0-9_"].repeat).as(:sname) }
50
+ rule(:sname_close) { match["a-z"] >> match["a-zA-Z0-9_"].repeat }
51
+
52
+ # Mixed bag: tokens (bare/grouped) AND kv pairs. Compiler partitions them apart.
53
+ rule(:args) { (sp >> attr).repeat.as(:args) }
54
+ rule(:attr) { grouped | kvattr | token }
55
+
56
+ rule(:grouped) do
57
+ (match["^:\\[\\s"].repeat(1).as(:prefix) >> str(":[") >>
58
+ (group_token >> (sp >> group_token).repeat).as(:inner) >> str("]")).as(:grouped)
59
+ end
60
+ # Same charset as `token`, but a bare "]" ends the token so the group's
61
+ # closing bracket is never swallowed. A balanced "[...]" run (arbitrary
62
+ # values like gap-[13px]) is consumed whole, so its inner "]" doesn't
63
+ # terminate the group prematurely.
64
+ rule(:group_token) do
65
+ (bracketed | (str("/") >> str(">").absent?) | match["^\\s/>\\[\\]"]).repeat(1).as(:token)
66
+ end
67
+ rule(:bracketed) { str("[") >> match["^\\]"].repeat >> str("]") }
68
+
69
+ rule(:kvattr) { (key.as(:key) >> str("=") >> (dquoted | unquoted)).as(:kv) }
70
+ rule(:key) { match["a-zA-Z_\\-"].repeat(1) }
71
+ rule(:dquoted) { str('"') >> match['^"'].repeat.as(:dq) >> str('"') }
72
+ rule(:unquoted) { match["^\\s/>"].repeat(1).as(:bare) }
73
+
74
+ rule(:token) { ((str("/") >> str(">").absent?) | match["^\\s/>"]).repeat(1).as(:token) }
75
+
76
+ rule(:sp) { match["\\s"].repeat(1) }
77
+ rule(:sp?) { sp.maybe }
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+ require_relative "parser"
5
+
6
+ module SpanView
7
+ module TemplateHandler
8
+ def self.call(template, source)
9
+ compiled_erb = Parser.call(source)
10
+ ActionView::Template.handler_for_extension(:erb).call(template, compiled_erb)
11
+ end
12
+ end
13
+ end
14
+
15
+ ActionView::Template.register_template_handler(:span, SpanView::TemplateHandler)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpanView
4
+ VERSION = "0.1.0"
5
+ end
data/lib/span_view.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+ require_relative "span_view/version"
5
+ require_relative "span_view/errors"
6
+ require_relative "span_view/parser"
7
+ require_relative "span_view/template_handler"
data/sig/span_view.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module SpanView
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: span_view
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Fabio Papa
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-07-19 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionview
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: parslet
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ description: Span the gap. ActionView .span template handler for the League system.
41
+ email:
42
+ - me@fabiopapa.dev
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - README.md
48
+ - Rakefile
49
+ - lib/span_view.rb
50
+ - lib/span_view/errors.rb
51
+ - lib/span_view/parser.rb
52
+ - lib/span_view/span/compiler.rb
53
+ - lib/span_view/span/grammar.rb
54
+ - lib/span_view/template_handler.rb
55
+ - lib/span_view/version.rb
56
+ - sig/span_view.rbs
57
+ homepage: https://github.com/fapapa/league/tree/main/gems/span_view
58
+ licenses: []
59
+ metadata:
60
+ allowed_push_host: https://rubygems.org
61
+ homepage_uri: https://github.com/fapapa/league/tree/main/gems/span_view
62
+ source_code_uri: https://github.com/fapapa/league/tree/main/gems/span_view
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: 3.2.0
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.6.2
78
+ specification_version: 4
79
+ summary: Span the gap.
80
+ test_files: []