Repository: yippee-fun/phlex (ruby)

Issue:
Better i18n support
Currently Phlex does not provide anything for i18n, one can include `I18n` and then use the translation helpers with absolute keys.

So while this works, adding the rails helpers for i18n will cause problems, when trying to use relative keys like `t(".new")`, it will raise an error, that there is no `@virtual_path` set.

ViewComponent solves this, by setting the `@virtual_path` in the `render_in` method. 

ViewComponent also supports having the language files as sidecar files, which is pretty neat, and might be a cool thing for Phlex as well.

Some of my research on the topic can be found here: https://discord.com/channels/629472241427415060/1002702966551216139/1022976024432095373


Discussion:
> as you can see, it's easy to include, but you also need to provide the @virtual_path variable. What are your thoughts about this, I feel like i18n should work out of the box.

I agree. Translation should work out of the box when it's available in Rails and we might even be able to provide translation features for non Rails users.

> It's a bit annoying, that an instance var is required. So I actually think, one has to overwrite the new method, as POROs don't really provide any after_initialize or something like that, or is there something along those lines?

If we need to do this, we should be able to do it in the `Renderable#render_in` method which is always called when rendering in Rails.

I’m not sure how I feel about the side car files. I would rather move the translations inline like we've done with templates, though I’m open to optional side car files for people who need translations to be separate for translation service API compatibility, etc.

I don't really like the dot syntax `t("a.b.c")` and would prefer something closer to dig `t(:a, :b, :c)` or even `t.a.b.c`, though I guess it makes sense to support the normal rails syntax for backwards compatibility even if we provide a cleaner API.

For performance, the compiler could inline this as something like `@translations[:a][:b][:c]`.

Patch:
diff --git a/Gemfile b/Gemfile
--- a/Gemfile
+++ b/Gemfile
@@ -14,6 +14,7 @@ gem "webrick", group: [:docs]
 gem "zeitwerk", group: [:docs]
 gem "redcarpet", group: [:docs]
 gem "combustion", group: [:test]
+gem "i18n", group: [:test]
 gem "benchmark-ips"
 gem "htmlbeautifier", group: [:docs]
 gem "benchmark-memory"
diff --git a/docs/components/example.rb b/docs/components/example.rb
--- a/docs/components/example.rb
+++ b/docs/components/example.rb
@@ -13,12 +13,12 @@ def template(&block)
 			end
 		end
 
-		def tab(name, code)
+		def tab(name, code, syntax: :ruby)
 			@t.tab(name) do
-				render CodeBlock.new(code, syntax: :ruby)
+				render CodeBlock.new(code, syntax: syntax)
 			end
 
-			@sandbox.class_eval(code)
+			@sandbox.class_eval(code) if syntax == :ruby
 		end
 
 		def execute(code)
diff --git a/docs/components/layout.rb b/docs/components/layout.rb
--- a/docs/components/layout.rb
+++ b/docs/components/layout.rb
@@ -51,6 +51,7 @@ def template(&block)
 										render Nav::Item.new("Views", to: Pages::Views, active_page: @_parent)
 										render Nav::Item.new("Templates", to: Pages::Templates, active_page: @_parent)
 										render Nav::Item.new("Helpers", to: Pages::Helpers, active_page: @_parent)
+										render Nav::Item.new("Translations", to: Pages::Translations, active_page: @_parent)
 									end
 
 									h2(class: "text-lg font-semibold pt-5") { "Testing" }
diff --git a/docs/pages/helpers.rb b/docs/pages/helpers.rb
--- a/docs/pages/helpers.rb
+++ b/docs/pages/helpers.rb
@@ -3,7 +3,7 @@
 module Pages
 	class Helpers < ApplicationPage
 		def template
-			render Layout.new(title: "Templates in Phlex") do
+			render Layout.new(title: "Helpers") do
 				render Markdown.new(<<~MD)
 					# Helpers
 
diff --git a/docs/pages/translations.rb b/docs/pages/translations.rb
new file mode 100644
--- /dev/null
+++ b/docs/pages/translations.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+module Pages
+	class Translations < ApplicationPage
+		def initialize
+			I18n.backend.store_translations(
+				"pt-BR", {
+					hello: "Olá",
+					views: { feedback: { welcome_message: { hello: "Olá" } } }
+				}
+			)
+			I18n.locale = "pt-BR"
+		end
+
+		def template
+			render Layout.new(title: "Translations") do
+				render Markdown.new(<<~MD)
+					# Translations
+
+					Phlex has built-in support for translations with the **[I18n Gem](https://github.com/ruby-i18n/i18n)**.
+
+					Just include `Phlex::Translation` in your view and use the `translate` method to access a translation.
+				MD
+
+				render Example.new do |e|
+					e.tab "welcome_message.rb", <<~RUBY
+						class WelcomeMessage < Phlex::View
+							include Phlex::Translation
+
+							def template
+								h1 { translate("hello") }
+							end
+						end
+					RUBY
+
+					e.tab "pt-PR.yml", <<~YAML, syntax: :yaml
+						pt-BR:
+						  hello: "Olá"
+					YAML
+
+					e.execute "WelcomeMessage.new.call"
+				end
+
+				render Markdown.new(<<~MD)
+					## Implicit scoopes
+
+					Start your translate key with a `.` to use the name of the view as an implicit scope.
+				MD
+
+				render Example.new do |e|
+					e.tab "welcome_message.rb", <<~RUBY
+						module Views
+							module Feedback
+								class WelcomeMessage < Phlex::View
+									include Phlex::Translation
+
+									def template
+										h1 { translate(".hello") }
+									end
+								end
+							end
+						end
+					RUBY
+
+					e.tab "pt-BR.yml", <<~YAML, syntax: :yaml
+						pt-BR:
+						  views:
+						    feedback:
+						      welcome_message:
+						        hello: Olá
+					YAML
+
+					e.execute <<~RUBY
+						Views::Feedback::WelcomeMessage.translation_path = 'views.feedback.welcome_message'
+						Views::Feedback::WelcomeMessage.new.call
+					RUBY
+				end
+			end
+		end
+	end
+end
diff --git a/fixtures/dummy/app/views/application_view.rb b/fixtures/dummy/app/views/application_view.rb
new file mode 100644
--- /dev/null
+++ b/fixtures/dummy/app/views/application_view.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Views
+	class ApplicationView < Phlex::View
+		include Rails.application.routes.url_helpers
+		include Phlex::Translation
+	end
+end
diff --git a/fixtures/dummy/app/views/articles/form.rb b/fixtures/dummy/app/views/articles/form.rb
--- a/fixtures/dummy/app/views/articles/form.rb
+++ b/fixtures/dummy/app/views/articles/form.rb
@@ -2,7 +2,7 @@
 
 module Views
 	module Articles
-		class Form < Phlex::View
+		class Form < ApplicationView
 			include Phlex::Rails::Helpers::FormWith
 
 			def template
diff --git a/fixtures/dummy/app/views/card.rb b/fixtures/dummy/app/views/card.rb
--- a/fixtures/dummy/app/views/card.rb
+++ b/fixtures/dummy/app/views/card.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 module Views
-	class Card < Phlex::View
+	class Card < ApplicationView
 		def template(&block)
 			article class: "drop-shadow p-5 rounded", &block
 		end
diff --git a/fixtures/dummy/app/views/comments/comment.rb b/fixtures/dummy/app/views/comments/comment.rb
--- a/fixtures/dummy/app/views/comments/comment.rb
+++ b/fixtures/dummy/app/views/comments/comment.rb
@@ -2,7 +2,7 @@
 
 module Views
 	module Comments
-		class Comment < Phlex::View
+		class Comment < ApplicationView
 			def initialize(name:, body:)
 				@name = name
 				@body = body
diff --git a/fixtures/dummy/app/views/comments/reaction.rb b/fixtures/dummy/app/views/comments/reaction.rb
--- a/fixtures/dummy/app/views/comments/reaction.rb
+++ b/fixtures/dummy/app/views/comments/reaction.rb
@@ -2,7 +2,7 @@
 
 module Views
 	module Comments
-		class Reaction < Phlex::View
+		class Reaction < ApplicationView
 			def initialize(emoji:)
 				@emoji = emoji
 			end
diff --git a/fixtures/dummy/app/views/heading.rb b/fixtures/dummy/app/views/heading.rb
--- a/fixtures/dummy/app/views/heading.rb
+++ b/fixtures/dummy/app/views/heading.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 module Views
-	class Heading < Phlex::View
+	class Heading < ApplicationView
 		def template(&block)
 			h1(&block)
 		end
diff --git a/lib/install/phlex.rb b/lib/install/phlex.rb
--- a/lib/install/phlex.rb
+++ b/lib/install/phlex.rb
@@ -22,6 +22,7 @@
 		module Views
 		  class ApplicationView < Phlex::View
 		    include Rails.application.routes.url_helpers
+		    include Phlex::Translation
 		  end
 		end
 	RUBY
diff --git a/lib/phlex/translation.rb b/lib/phlex/translation.rb
new file mode 100644
--- /dev/null
+++ b/lib/phlex/translation.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Phlex
+	module Translation
+		def self.included(view)
+			view.extend(ClassMethods)
+		end
+
+		module ClassMethods
+			attr_writer :translation_path
+
+			def translation_path
+				@translation_path ||= name&.split("::")&.join(".")&.downcase.to_s
+			end
+		end
+
+		def translate(key, **options)
+			key = "#{self.class.translation_path}#{key}" if key.start_with?(".")
+
+			::I18n.translate(key, **options)
+		end
+	end
+end


Test patch:
diff --git a/test/phlex/translation.rb b/test/phlex/translation.rb
new file mode 100644
--- /dev/null
+++ b/test/phlex/translation.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+describe Phlex::Translation do
+	extend ViewHelper
+
+	describe "#translate" do
+		with "key starting with '.'" do
+			view do
+				include Phlex::Translation
+
+				self.translation_path = "articles.card"
+
+				def template
+					text translate(".hello")
+				end
+			end
+
+			it "returns translation using translation_path as scope" do
+				expect(I18n).to receive(:translate).with("articles.card.hello").and_return("Olá")
+
+				expect(output).to be == "Olá"
+			end
+		end
+
+		with "key not starting with '.'" do
+			view do
+				include Phlex::Translation
+
+				self.translation_path = "articles.card"
+
+				def template
+					text translate("hello")
+				end
+			end
+
+			it "returns translation using key without translation_path as scope" do
+				expect(I18n).to receive(:translate).with("hello").and_return("hola")
+
+				expect(output).to be == "hola"
+			end
+		end
+	end
+
+	describe ".translation_path" do
+		with "valid class name" do
+			it "returns I18n scope using class anme" do
+				expect(Views::Articles::Form.translation_path).to be == "views.articles.form"
+			end
+		end
+
+		with "invalid class_name" do
+			it "returns empty string" do
+				view = Class.new(Phlex::View) do
+					include Phlex::Translation
+				end
+
+				expect(view.translation_path).to be == ""
+			end
+		end
+
+		it "memoizes result" do
+			Views::Articles::Form.translation_path # memoize `translation_path` with original result: "articles.form"
+
+			# Mock const_source_location. If memoization doesn't work, the `translation_path` should be "posts.form"
+			mock(Views::Articles::Form) do |mock|
+				mock.replace(:const_source_location) do
+					["/my-app/app/views/posts/form.rb", 5]
+				end
+			end
+
+			expect(Views::Articles::Form.translation_path).to be == "views.articles.form"
+		end
+	end
+end