antoinette 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 275b763ee10f623e9fd499510bcd6da5dde6a8c99d38a0cd1dfd91f97de569ef
4
- data.tar.gz: 632150c5438f87310f1c10112d412a7dbcafa0abb081404f534069ae08e49d02
3
+ metadata.gz: 795388e48b4799e7b321bc12ce0050797fee44d3bc6008f289c7367efe839dbd
4
+ data.tar.gz: 788964274ce4ed2c0061017aee248dd58e93864d86ce56eefe65b9d13f2a115a
5
5
  SHA512:
6
- metadata.gz: c47b5b82eee6fee5e9c597f1fae507948acaa1e14696b5b6086f712593dd4fc24a1e99b20d16f98351744802deca6794a5d85ff7b10b2ab2c82d5f83ac5871ad
7
- data.tar.gz: ec7681daf22bf110d3a39b005969d1959c88f63a4234ebb5e15b7768bb16ab3aaea4fb77d5cd7538fc01ba8e28329e9f6c9e1caf2b6e3d17b41434e95a460fe3
6
+ metadata.gz: 329ab030c6192c1af402741a0050c67059a353e8a16c3f445a99343b600666d6c1c9062d7a78ccddbf0fbc8f751c63c835d4953d9b855dd3da84b7b6458217b2
7
+ data.tar.gz: 778cd2d85170e35ef3ff5a6ffe987eef2d6c728a2072466336e13307847b1ba9eec6d0b2a89fa19dc44227529d065482a9801deeb7b6b4235abb3905c7b00d8a
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2024
4
+
5
+ - Initial extraction from Gridular Mod
6
+ - Core functionality:
7
+ - Elm app usage analysis
8
+ - Bundle generation with haiku-styled names
9
+ - Script tag injection (idempotent)
10
+ - Partial template resolution
11
+ - CLI commands: config, build, clear, update
12
+ - Rails engine with admin dashboard
13
+ - Install generator
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2026 Giles Bowkett
3
+ Copyright (c) 2024 Giles Bowkett
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,43 +1,144 @@
1
1
  # Antoinette
2
2
 
3
- 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/antoinette`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ ![Actors from the Sofia Coppola "Marie Antoinette" film](images/antoinette.png)
4
4
 
5
- TODO: Delete this and the text above, and describe your gem
5
+ Imagine you have a Rails app with numerous Elm features. You haven't gone the
6
+ SPA route; some Elm apps control the entire page, but many just provide bits
7
+ and pieces of useful functionality. This is [the officially recommended way to
8
+ bring Elm into a project](https://elm-lang.org/news/how-to-use-elm-at-work);
9
+ Evan Czaplicki called it "_the_ success path."
10
+
11
+ However, if you take that far enough, you reach a threshold where you have a
12
+ bunch of little Elm features throughout your site. If you don't want to switch
13
+ to an SPA, but you don't also want to send code down the wire that your app
14
+ won't use, you're at a crossroads.
15
+
16
+ This is the problem Antoinette solves.
17
+
18
+ Antoinette is a lightweight JS bundler which weaves Elm apps into JavaScript
19
+ bundles, and weaves JavaScript bundles into Rails templates.
20
+
21
+ The name comes from mansion weave, a style of flooring based on woven elm wood,
22
+ which was popular in French mansions from the 16th century onwards.
6
23
 
7
24
  ## Installation
8
25
 
9
26
  Add this line to your application's Gemfile:
10
27
 
11
28
  ```ruby
12
- gem 'antoinette'
29
+ gem "antoinette"
13
30
  ```
14
31
 
15
- And then execute:
32
+ Run the installer:
16
33
 
17
- $ bundle
34
+ ```bash
35
+ bin/rails generate antoinette:install
36
+ ```
18
37
 
19
- Or install it yourself as:
38
+ This creates:
39
+ - `config/antoinette.json` - Bundle configuration
40
+ - `app/client/` - Directory for Elm source files
41
+ - `app/client/BundleGraph.elm` and `app/client/Sankey.elm` - Admin visualization
42
+ - `bin/antoinette` - CLI binstub
43
+ - `app/assets/javascripts/antoinette/` - Bundle output directory
20
44
 
21
- $ gem install antoinette
45
+ It also adds a route for `/antoinette` admin page, and adds
46
+ `app/assets/javascripts/antoinette` to your `.gitignore`.
22
47
 
23
48
  ## Usage
24
49
 
25
- TODO: Write usage instructions here
50
+ ### Configuration
26
51
 
27
- ## Development
52
+ Generate bundle configuration by analyzing which Elm apps are used in your Rails views:
28
53
 
29
- After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
54
+ ```bash
55
+ bin/antoinette config
56
+ ```
30
57
 
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 tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
58
+ To include custom view directories (outside `app/views/`):
32
59
 
33
- ## Contributing
60
+ ```bash
61
+ bin/antoinette config --custom_views app/content/layouts/
62
+ ```
34
63
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/antoinette. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
64
+ ### Building
36
65
 
37
- ## License
66
+ Compile all Elm bundles and inject script tags into templates:
67
+
68
+ ```bash
69
+ bin/antoinette build
70
+ ```
71
+
72
+ ### Updating Specific Apps
73
+
74
+ Rebuild only the bundles for specific Elm apps:
75
+
76
+ ```bash
77
+ bin/antoinette update app/client/SearchForm.elm app/client/CaseBuilder.elm
78
+ ```
79
+
80
+ ### Clearing
81
+
82
+ Remove all generated bundles and script tags:
83
+
84
+ ```bash
85
+ bin/antoinette clear
86
+ ```
87
+
88
+ ### Admin Dashboard
38
89
 
39
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
90
+ Visit `/antoinette` to see an interactive Sankey diagram showing how Elm apps
91
+ flow into bundles and then into Rails templates.
40
92
 
41
- ## Code of Conduct
93
+ ## How It Works
94
+
95
+ 1. **Analysis**: Scans Rails views for `Elm.AppName.init` patterns
96
+ 2. **Grouping**: Groups templates that use the same combination of Elm apps
97
+ 3. **Bundling**: Compiles each group into a single JavaScript bundle (with a haiku-styled name like `holy-waterfall-8432`)
98
+ 4. **Injection**: Adds `javascript_include_tag` to templates with SHA1 digest comments for idempotent updates
99
+
100
+ ### Script Tag Format
101
+
102
+ Antoinette injects script tags like:
103
+
104
+ ```erb
105
+ <%= javascript_include_tag "antoinette/holy-waterfall-8432" %> <!-- antoinette a1b2c3d4... -->
106
+ ```
107
+
108
+ Embedding a hash in the comment ensures that tags only get updated when bundle content changes.
109
+
110
+ ## Requirements
111
+
112
+ - Rails 7.0+
113
+ - Elm (customize via `elm_path` in `config/antoinette.json`)
114
+
115
+ ## Configuration
116
+
117
+ The `config/antoinette.json` file structure:
118
+
119
+ ```json
120
+ {
121
+ "elm_path": "elm",
122
+ "bundles": [
123
+ {
124
+ "name": "holy-waterfall-8432",
125
+ "elm_apps": ["CaseBuilder", "SearchForm"],
126
+ "templates": ["app/views/cases/new.html.erb"]
127
+ }
128
+ ],
129
+ "custom_view_paths": ["app/content/layouts/"]
130
+ }
131
+ ```
132
+
133
+ ## Rake Integration
134
+
135
+ The `antoinette:build` task runs automatically before `assets:precompile`:
136
+
137
+ ```bash
138
+ rake antoinette:build
139
+ rake assets:precompile # runs antoinette:build first
140
+ ```
141
+
142
+ ## License
42
143
 
43
- Everyone interacting in the Antoinette project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/antoinette/blob/master/CODE_OF_CONDUCT.md).
144
+ MIT
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require "json"
5
+
6
+ module Antoinette
7
+ module CLI
8
+ def self.output
9
+ Rails.env.test? ? StringIO.new : $stdout
10
+ end
11
+
12
+ module Commands
13
+ extend Dry::CLI::Registry
14
+
15
+ class Config < Dry::CLI::Command
16
+ desc "Generate JSON configuration for Elm bundles"
17
+
18
+ option :stdout, type: :boolean, default: false, desc: "Output to stdout instead of file"
19
+ option :custom_views, type: :array, default: [], desc: "Additional view directories to scan"
20
+
21
+ def call(stdout:, custom_views: [], **)
22
+ out = Antoinette::CLI.output
23
+ config_path = Rails.root.join("config", "antoinette.json")
24
+
25
+ existing_config = if File.exist?(config_path)
26
+ JSON.parse(File.read(config_path))
27
+ else
28
+ {}
29
+ end
30
+ existing_custom = existing_config["custom_view_paths"] || []
31
+ existing_elm_path = existing_config["elm_path"] || "elm"
32
+ all_custom_views = (existing_custom + custom_views).uniq
33
+
34
+ analyzer = Antoinette::ElmAppUsageAnalyzer.new(
35
+ skip: "layouts/",
36
+ custom_view_paths: all_custom_views
37
+ )
38
+ weaver = Antoinette::Weaver.new(
39
+ elm_analyzer: analyzer,
40
+ custom_view_paths: all_custom_views
41
+ )
42
+
43
+ output = JSON.parse(weaver.generate_json)
44
+ output["elm_path"] = existing_elm_path
45
+ json_output = JSON.pretty_generate(output)
46
+
47
+ if stdout
48
+ out.puts json_output
49
+ else
50
+ File.write(config_path, json_output)
51
+ out.puts "Generated #{config_path}"
52
+ end
53
+ end
54
+ end
55
+
56
+ class Build < Dry::CLI::Command
57
+ desc "Build JavaScript bundles from config"
58
+
59
+ def call(**)
60
+ out = Antoinette::CLI.output
61
+ config_path = Rails.root.join("config", "antoinette.json")
62
+ config = JSON.parse(File.read(config_path))
63
+
64
+ elm_path = config["elm_path"] || "elm"
65
+ compiler = Antoinette::CompileElm.new(elm_path: elm_path)
66
+ concatenator = Antoinette::ConcatBundle.new
67
+ injector = Antoinette::InjectScriptTag.new
68
+
69
+ config["bundles"].each do |bundle|
70
+ out.puts "Building bundle: #{bundle["name"]}"
71
+
72
+ elm_js = compiler.compile(bundle["elm_apps"])
73
+ concatenator.concatenate(
74
+ bundle_name: bundle["name"],
75
+ elm_js: elm_js
76
+ )
77
+
78
+ bundle["templates"].each do |template_path|
79
+ injector.inject(template_path: template_path, bundle_name: bundle["name"])
80
+ end
81
+
82
+ out.puts " Compiled Elm apps: #{bundle["elm_apps"].join(", ")}"
83
+ out.puts " Injected script tags into #{bundle["templates"].length} template(s)"
84
+ end
85
+
86
+ out.puts "Build complete!"
87
+ end
88
+ end
89
+
90
+ class Clear < Dry::CLI::Command
91
+ desc "Clear generated bundles and script tags"
92
+
93
+ def call(**)
94
+ out = Antoinette::CLI.output
95
+ config_path = Rails.root.join("config", "antoinette.json")
96
+ config = JSON.parse(File.read(config_path))
97
+
98
+ clearer = Antoinette::ClearScriptTag.new
99
+
100
+ config["bundles"].each do |bundle|
101
+ out.puts "Clearing bundle: #{bundle["name"]}"
102
+
103
+ bundle_file = Rails.root.join(
104
+ "app", "assets", "javascripts", "antoinette", "#{bundle["name"]}.js"
105
+ )
106
+ if File.exist?(bundle_file)
107
+ File.delete(bundle_file)
108
+ out.puts " Deleted bundle file: #{bundle["name"]}.js"
109
+ end
110
+
111
+ bundle["templates"].each do |template_path|
112
+ clearer.clear(template_path: template_path)
113
+ end
114
+
115
+ out.puts " Cleared script tags from #{bundle["templates"].length} template(s)"
116
+ end
117
+
118
+ out.puts "Clear complete!"
119
+ end
120
+ end
121
+
122
+ class Update < Dry::CLI::Command
123
+ desc "Update bundle(s) and script tag(s) for specific Elm apps"
124
+
125
+ argument :elm_files, type: :array, required: true, desc: "Elm file paths"
126
+
127
+ def call(elm_files:, **)
128
+ out = Antoinette::CLI.output
129
+ elm_app_names = elm_files.map { |path| File.basename(path, ".elm") }
130
+
131
+ config_path = Rails.root.join("config", "antoinette.json")
132
+ config = JSON.parse(File.read(config_path))
133
+
134
+ filtered_bundles = config["bundles"].select do |bundle|
135
+ (bundle["elm_apps"] & elm_app_names).any?
136
+ end
137
+
138
+ if filtered_bundles.empty?
139
+ out.puts "No bundles found containing: #{elm_app_names.join(", ")}"
140
+ exit
141
+ end
142
+
143
+ elm_path = config["elm_path"] || "elm"
144
+ compiler = Antoinette::CompileElm.new(elm_path: elm_path)
145
+ concatenator = Antoinette::ConcatBundle.new
146
+ injector = Antoinette::InjectScriptTag.new
147
+
148
+ filtered_bundles.each do |bundle|
149
+ out.puts "Updating bundle: #{bundle["name"]}"
150
+
151
+ elm_js = compiler.compile(bundle["elm_apps"])
152
+ concatenator.concatenate(
153
+ bundle_name: bundle["name"],
154
+ elm_js: elm_js
155
+ )
156
+
157
+ bundle["templates"].each do |template_path|
158
+ injector.inject(template_path: template_path, bundle_name: bundle["name"])
159
+ end
160
+
161
+ out.puts " Compiled Elm apps: #{bundle["elm_apps"].join(", ")}"
162
+ out.puts " Injected script tags into #{bundle["templates"].length} template(s)"
163
+ end
164
+
165
+ out.puts "Update complete!"
166
+ end
167
+ end
168
+
169
+ register "config", Config
170
+ register "build", Build
171
+ register "clear", Clear
172
+ register "update", Update
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Antoinette
4
+ class Engine < ::Rails::Engine
5
+ initializer "antoinette.assets" do |app|
6
+ app.config.assets.paths << root.join("app", "assets", "javascripts")
7
+ end
8
+
9
+ config.generators do |g|
10
+ g.test_framework :rspec
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Antoinette
4
+ class ClearScriptTag
5
+ def initialize(views_path: Rails.root.join("app", "views"))
6
+ @views_path = views_path
7
+ end
8
+
9
+ def clear(template_path:)
10
+ full_path = resolve_template_path(template_path)
11
+ content = File.read(full_path)
12
+
13
+ return unless content.match?(/<!-- antoinette [a-f0-9]+ -->/)
14
+
15
+ updated_content = content.gsub(/^.*<!-- antoinette [a-f0-9]+ -->.*\n?/, "")
16
+
17
+ File.write(full_path, updated_content)
18
+ end
19
+
20
+ private
21
+
22
+ def resolve_template_path(template_path)
23
+ if template_path.start_with?("app/")
24
+ Rails.root.join(template_path)
25
+ else
26
+ @views_path.join(template_path)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uglifier"
4
+
5
+ module Antoinette
6
+ class CompileElm
7
+ ELM_PURE_FUNCS = %w[F2 F3 F4 F5 F6 F7 F8 F9 A2 A3 A4 A5 A6 A7 A8 A9].freeze
8
+
9
+ def initialize(elm_path: "elm", environment: Rails.env.to_s)
10
+ @elm_path = elm_path
11
+ @environment = environment
12
+ end
13
+
14
+ def compile(elm_app_names)
15
+ elm_file_paths = elm_app_names.map { |name| "app/client/#{name}.elm" }
16
+ output_file = "tmp/elm_compiled.js"
17
+ optimize_flag = production? ? "--optimize" : ""
18
+
19
+ command = "#{@elm_path} make #{optimize_flag} --output=#{output_file} #{elm_file_paths.join(" ")}".squeeze(" ")
20
+
21
+ system(command)
22
+
23
+ unless Process.last_status.success?
24
+ raise "Elm compilation failed"
25
+ end
26
+
27
+ js = File.read(output_file)
28
+ production? ? minify(js) : js
29
+ ensure
30
+ File.delete(output_file) if output_file && File.exist?(output_file)
31
+ end
32
+
33
+ private
34
+
35
+ def production?
36
+ @environment == "production"
37
+ end
38
+
39
+ def minify(js)
40
+ Uglifier.compile(
41
+ js,
42
+ compress: {
43
+ pure_funcs: ELM_PURE_FUNCS,
44
+ pure_getters: true,
45
+ keep_fargs: false,
46
+ unsafe_comps: true,
47
+ unsafe: true
48
+ },
49
+ mangle: true
50
+ )
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Antoinette
4
+ class ConcatBundle
5
+ def initialize(assets_path: Rails.root.join("app", "assets", "javascripts", "antoinette"))
6
+ @assets_path = assets_path
7
+ end
8
+
9
+ def concatenate(bundle_name:, elm_js:)
10
+ output_path = @assets_path.join("#{bundle_name}.js")
11
+ File.write(output_path, elm_js)
12
+
13
+ output_path.to_s
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Antoinette
6
+ class ElmAppUsageAnalyzer
7
+ ViewFile = Struct.new(:path, :elm_apps)
8
+ ElmApp = Struct.new(:name)
9
+ class Matrix < Hash; end
10
+
11
+ def initialize(skip: nil, custom_view_paths: [])
12
+ @skip = skip
13
+ @custom_view_paths = custom_view_paths
14
+ end
15
+
16
+ def views
17
+ @views ||= view_path_globs.each_with_object([]) do |glob_pattern, result|
18
+ Dir.glob(glob_pattern).each do |file_path|
19
+ content = File.read(file_path)
20
+ apps = elm_apps(content)
21
+ next if apps.empty?
22
+
23
+ relative_path = file_path.sub("#{Rails.root}/", "")
24
+ next if @skip && relative_path.include?("app/views/#{@skip}")
25
+
26
+ app_names = apps.map(&:name)
27
+ result << ViewFile.new(relative_path, app_names)
28
+ end
29
+ end
30
+ end
31
+
32
+ def view_path_globs
33
+ globs = [Rails.root.join("app", "views", "**", "*.html.erb")]
34
+ @custom_view_paths.each do |custom_path|
35
+ full_path = Rails.root.join(custom_path)
36
+ globs << if File.file?(full_path)
37
+ full_path
38
+ else
39
+ full_path.join("**", "*.html.erb")
40
+ end
41
+ end
42
+ globs
43
+ end
44
+
45
+ def elm_apps(content)
46
+ app_names = content.scan(/Elm\.(\w+)\.init/).flatten.uniq
47
+ app_names.map { |name| ElmApp.new(name) }
48
+ end
49
+
50
+ def matrix
51
+ @matrix ||= Matrix.new.tap do |m|
52
+ all_app_names.each do |app_name|
53
+ m[app_name] = views.select { |v| v.elm_apps.include?(app_name) }
54
+ .map(&:path)
55
+ end
56
+ end
57
+ end
58
+
59
+ def all_app_names
60
+ @all_app_names ||= views.flat_map(&:elm_apps).uniq.sort
61
+ end
62
+
63
+ def layout_apps
64
+ @layout_apps ||= Dir.glob(Rails.root.join("app", "views", "layouts", "*.html.erb"))
65
+ .flat_map do |file_path|
66
+ content = File.read(file_path)
67
+ elm_apps(content).map(&:name)
68
+ end.uniq
69
+ end
70
+
71
+ def per_file
72
+ @per_file ||= views.sort_by { |vf| -vf.elm_apps.count }
73
+ .each_with_object({}) do |view_file, result|
74
+ result[view_file.path] = view_file.elm_apps
75
+ end
76
+ end
77
+
78
+ def mappings
79
+ @mappings ||= views.group_by { |vf| vf.elm_apps.sort }
80
+ .sort_by { |apps, _| -apps.count }
81
+ .each_with_object({}) do |(apps, view_files), result|
82
+ result[apps] = view_files.map(&:path).sort
83
+ end
84
+ end
85
+
86
+ def generate_csv
87
+ CSV.generate do |csv|
88
+ csv << ["View File"] + all_app_names
89
+
90
+ views.each do |view_file|
91
+ row = [view_file.path]
92
+ all_app_names.each do |app_name|
93
+ row << (view_file.elm_apps.include?(app_name) ? "X" : "")
94
+ end
95
+ csv << row
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Antoinette
6
+ class InjectScriptTag
7
+ def initialize(
8
+ assets_path: Rails.root.join("app", "assets", "javascripts", "antoinette")
9
+ )
10
+ @assets_path = assets_path
11
+ end
12
+
13
+ def inject(template_path:, bundle_name:)
14
+ full_path = Rails.root.join(template_path)
15
+ content = File.read(full_path)
16
+
17
+ bundle_path = @assets_path.join("#{bundle_name}.js")
18
+ digest = Digest::SHA1.hexdigest(File.read(bundle_path))
19
+ script_tag = "<%= javascript_include_tag \"antoinette/#{bundle_name}\" %> <!-- antoinette #{digest} -->"
20
+
21
+ updated_content = if content.match?(/<!-- antoinette [a-f0-9]+ -->/)
22
+ content.gsub(/^.*<!-- antoinette [a-f0-9]+ -->.*$/, script_tag)
23
+ else
24
+ content + "\n" + script_tag
25
+ end
26
+
27
+ File.write(full_path, updated_content)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Antoinette
4
+ class PartialResolver
5
+ RenderCall = Struct.new(:template_path, :partial_path)
6
+
7
+ def renders
8
+ @renders ||= begin
9
+ view_files = Dir.glob(Rails.root.join("app", "views", "**", "*.html.erb"))
10
+
11
+ view_files.each_with_object([]) do |file_path, result|
12
+ content = File.read(file_path)
13
+ relative_template_path = file_path.sub("#{Rails.root}/app/views/", "")
14
+ partial_paths = extract_partial_paths(content, relative_template_path)
15
+ next if partial_paths.empty?
16
+
17
+ partial_paths.each do |partial_path|
18
+ result << RenderCall.new(relative_template_path, partial_path)
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ def extract_partial_paths(content, template_path = nil)
25
+ paths = []
26
+
27
+ content.scan(/render\s+partial:\s*["']([^"']+)["']/) do |match|
28
+ paths << normalize_partial_path(match[0], template_path)
29
+ end
30
+
31
+ content.scan(/render\s+["']([^"']+)["']/) do |match|
32
+ paths << normalize_partial_path(match[0], template_path)
33
+ end
34
+
35
+ paths.uniq
36
+ end
37
+
38
+ def normalize_partial_path(path, template_path = nil)
39
+ if path.include?("/")
40
+ dir, name = path.split("/")[0..-2].join("/"), path.split("/").last
41
+ name = name.start_with?("_") ? name : "_#{name}"
42
+ "#{dir}/#{name}.html.erb"
43
+ else
44
+ name = path.start_with?("_") ? path : "_#{path}"
45
+
46
+ if template_path
47
+ template_dir = File.dirname(template_path)
48
+ "#{template_dir}/#{name}.html.erb"
49
+ else
50
+ "#{name}.html.erb"
51
+ end
52
+ end
53
+ end
54
+
55
+ def partials
56
+ @partials ||= begin
57
+ grouped = renders.group_by(&:partial_path)
58
+ grouped.transform_values do |render_calls|
59
+ render_calls.map(&:template_path).sort
60
+ end
61
+ end
62
+ end
63
+
64
+ def resolve(partial_path)
65
+ partials[partial_path] || []
66
+ end
67
+ end
68
+ end