diff --git a/docs/docs/templates/reference/tags.md b/docs/docs/templates/reference/tags.md index e047d1f9d..bca10c521 100644 --- a/docs/docs/templates/reference/tags.md +++ b/docs/docs/templates/reference/tags.md @@ -281,6 +281,29 @@ Additionally, it is important to note that the accessibility of outer context va Templates that are included using the `include` template are parsed and rendered _when_ the including template is rendered as well. Included templates are not parsed when the including template is parsed itself. This means that the including template and the included template are always rendered _separately_. ::: +## `localize` + +The `localize` template tag allows performing localization of values such as dates, numbers, and times by using the [I18n gem](https://crystal-i18n.github.io/localization.html), which is leveraged by Marten for its [internationalization features](../../i18n/introduction.md). It must take at least one argument (the value to localize) followed by an optional `format` keyword argument. + +For example, the following lines are valid usages of the `localize` tag: + +```html +{% localize created_at %} +{% localize price format: "currency" %} +``` + +The provided values and the `format` argument can be resolved as template variables too, but they can also be defined as literal values if necessary. The `format` argument must match a key defined in the locale file. + +Optionally, the result of the localization can be assigned to a specific variable using the `as` keyword: + +```html +{% localize created_at format: "short" as localized_date %} +``` + +## `l` + +Alias for [`localize`](#localize). + ## `local_time` The `local_time` template tag allows to output the string representation of the local time. It must take one argument (the [format](https://crystal-lang.org/api/Time/Format.html) used to output the time). diff --git a/spec/marten/template/tag/localize_spec.cr b/spec/marten/template/tag/localize_spec.cr new file mode 100644 index 000000000..5226dae0e --- /dev/null +++ b/spec/marten/template/tag/localize_spec.cr @@ -0,0 +1,130 @@ +require "./spec_helper" + +describe Marten::Template::Tag::Localize do + describe "::new" do + it "raises if the localize tag does not contain at least one argument" do + parser = Marten::Template::Parser.new("") + + expect_raises( + Marten::Template::Errors::InvalidSyntax, + "Malformed localize tag: at least one argument must be provided" + ) do + Marten::Template::Tag::Localize.new(parser, "localize") + end + end + end + + describe "#render" do + fixed_date_time = Time.utc(2024, 11, 19, 12, 45) + fixed_date_time_default_format = "Tue, 19 Nov 2024 12:45:00 +0000" # "%a, %d %b %Y %H:%M:%S %z" + fixed_date_time_long_format = "November 19, 2024 12:45" # "%B %d, %Y %H:%M" + fixed_date_time_short_format = "19 Nov 12:45" # "%d %b %H:%M" + fixed_date_default_format = "2024-11-19" # "%Y-%m-%d" + fixed_date_long_format = "November 19, 2024" # "%B %d, %Y" + fixed_date_short_format = "Nov 19" # "%b %d" + + it "is able to localize a simple numeric value" do + parser = Marten::Template::Parser.new("") + tag = Marten::Template::Tag::Localize.new(parser, %{localize 100000}) + tag.render(Marten::Template::Context.new).should eq "100,000" # Default number format + end + + it "is able to localize a time value" do + parser = Marten::Template::Parser.new("") + tag = Marten::Template::Tag::Localize.new(parser, %{localize date}) + tag.render(Marten::Template::Context{"date" => fixed_date_time}).should eq fixed_date_time_default_format + end + + it "is able to localize a date value" do + parser = Marten::Template::Parser.new("") + tag = Marten::Template::Tag::Localize.new(parser, %{localize date}) + tag.render(Marten::Template::Context{"date" => fixed_date_time.date}).should eq fixed_date_default_format + end + + it "is able to localize a time with a custom format" do + parser = Marten::Template::Parser.new("") + tag = Marten::Template::Tag::Localize.new(parser, %{localize date format: "short"}) + tag.render(Marten::Template::Context{"date" => fixed_date_time}).should eq fixed_date_time_short_format + end + + it "is able to localize a date with a custom format" do + parser = Marten::Template::Parser.new("") + tag = Marten::Template::Tag::Localize.new(parser, %{localize date format: "short"}) + tag.render(Marten::Template::Context{"date" => fixed_date_time.date}).should eq fixed_date_short_format + end + + it "is able to localize a time with a long format" do + parser = Marten::Template::Parser.new("") + tag = Marten::Template::Tag::Localize.new(parser, %{localize date format: "long"}) + tag.render(Marten::Template::Context{"date" => fixed_date_time}).should eq fixed_date_time_long_format + end + + it "is able to localize a date with a long format" do + parser = Marten::Template::Parser.new("") + tag = Marten::Template::Tag::Localize.new(parser, %{localize date format: "long"}) + tag.render(Marten::Template::Context{"date" => fixed_date_time.date}).should eq fixed_date_long_format + end + + it "is able to resolve values from the context for localization" do + parser = Marten::Template::Parser.new("") + context = Marten::Template::Context{"value" => 123456} + + tag = Marten::Template::Tag::Localize.new(parser, %{localize value}) + tag.render(context).should eq "123,456" + end + + it "is able to resolve the format from the context" do + parser = Marten::Template::Parser.new("") + context = Marten::Template::Context{"format" => "custom"} + + tag = Marten::Template::Tag::Localize.new(parser, %{localize 100000 format: format}) + tag.render(context).should eq "10-00-00,00" + end + + it "is able to assign the localized value to a specific variable" do + parser = Marten::Template::Parser.new("") + context = Marten::Template::Context{"date" => fixed_date_time} + tag = Marten::Template::Tag::Localize.new(parser, %{localize date as localized_date}) + + tag.render(context).should eq "" + context["localized_date"].to_s.should eq fixed_date_time_default_format + end + + it "raises if a invalid date (too many fields) tuple is provided" do + parser = Marten::Template::Parser.new("") + tag = Marten::Template::Tag::Localize.new(parser, %{localize unsupported_date}) + + expect_raises( + Marten::Template::Errors::UnsupportedValue, + "Localization of dates requires an Array with exactly 3 elements, but received 4 elements." + + " Ensure the Array follows the format [year, month, day]." + ) do + tag.render(Marten::Template::Context{"unsupported_date" => {2024, 11, 19, 12}}) + end + end + + it "raises if a invalid date (types) tuple is provided" do + parser = Marten::Template::Parser.new("") + tag = Marten::Template::Tag::Localize.new(parser, %{localize unsupported_date}) + + expect_raises( + Marten::Template::Errors::UnsupportedType, + "Expected an Array with only Int32 elements, but found elements of types: String, Bool, Float64." + ) do + tag.render(Marten::Template::Context{"unsupported_date" => {"2024", true, 1.0}}) + end + end + + it "raises if a non-supported type is provided" do + parser = Marten::Template::Parser.new("") + tag = Marten::Template::Tag::Localize.new(parser, %{localize unsupported_value}) + + expect_raises( + Marten::Template::Errors::UnsupportedType, + "The `localize` tag only supports localization of Time or numeric values, but got Nil" + ) do + tag.render(Marten::Template::Context{"unsupported_value" => nil}) + end + end + end +end diff --git a/spec/marten/template/tag_spec.cr b/spec/marten/template/tag_spec.cr index 21ec891b4..bedab1d37 100644 --- a/spec/marten/template/tag_spec.cr +++ b/spec/marten/template/tag_spec.cr @@ -3,7 +3,7 @@ require "./spec_helper" describe Marten::Template::Tag do describe "::get" do it "returns the right built-in tag classes for the expected tag names" do - Marten::Template::Tag.registry.size.should eq 24 + Marten::Template::Tag.registry.size.should eq 26 Marten::Template::Tag.get("asset").should eq Marten::Template::Tag::Asset Marten::Template::Tag.get("assign").should eq Marten::Template::Tag::Assign Marten::Template::Tag.get("block").should eq Marten::Template::Tag::Block @@ -16,6 +16,8 @@ describe Marten::Template::Tag do Marten::Template::Tag.get("for").should eq Marten::Template::Tag::For Marten::Template::Tag.get("if").should eq Marten::Template::Tag::If Marten::Template::Tag.get("include").should eq Marten::Template::Tag::Include + Marten::Template::Tag.get("localize").should eq Marten::Template::Tag::Localize + Marten::Template::Tag.get("l").should eq Marten::Template::Tag::Localize Marten::Template::Tag.get("local_time").should eq Marten::Template::Tag::LocalTime Marten::Template::Tag.get("method_input").should eq Marten::Template::Tag::MethodInput Marten::Template::Tag.get("reverse").should eq Marten::Template::Tag::Url diff --git a/spec/test_project/locales/en.yml b/spec/test_project/locales/en.yml index f9246cd3a..6adfff6e8 100644 --- a/spec/test_project/locales/en.yml +++ b/spec/test_project/locales/en.yml @@ -1,4 +1,12 @@ en: + i18n: + number: + formats: + custom: + delimiter: "-" + separator: "," + decimal_places: 2 + group: 2 simple: translation: "This is a simple translation" interpolation: "Hello, %{name}!" diff --git a/src/marten/locales/de.yml b/src/marten/locales/de.yml index e6e3ffca9..4b86bda10 100644 --- a/src/marten/locales/de.yml +++ b/src/marten/locales/de.yml @@ -1,4 +1,22 @@ de: + i18n: + date: + abbr_day_names: [Mo, Di, Mi, Do, Fr, Sa, So] + abbr_month_names: [Jan, Feb, Mär, Apr, Mai, Jun, Jul, Aug, Sep, Okt, Nov, Dez] + day_names: [Montag, Dienstag, Mittwoch, Donnerstag, Freitag, Samstag, Sonntag] + month_names: [Januar, Februar, März, April, Mai, Juni, + Juli, August, September, Oktober, November, Dezember] + formats: + default: "%d.%m.%Y" + long: "%d. %B %Y" + short: "%d.%m." + time: + am: vorm. + formats: + default: "%a, %d. %b %Y %H:%M:%S %z" + long: "%d. %B %Y %H:%M" + short: "%d. %b %H:%M" + pm: nachm. marten: db: field: @@ -86,4 +104,4 @@ de: invalid: "Geben Sie eine gültige URL ein." uuid: errors: - invalid: "Geben Sie eine gültige UUID ein." \ No newline at end of file + invalid: "Geben Sie eine gültige UUID ein." diff --git a/src/marten/locales/en.yml b/src/marten/locales/en.yml index 671e63369..490553afa 100644 --- a/src/marten/locales/en.yml +++ b/src/marten/locales/en.yml @@ -1,4 +1,22 @@ en: + i18n: + date: + abbr_day_names: [Mon, Tue, Wed, Thu, Fri, Sat, Sun] + abbr_month_names: [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec] + day_names: [Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday] + month_names: [January, February, March, April, May, June, + July, August, September, October, November, December] + formats: + default: "%Y-%m-%d" + long: "%B %d, %Y" + short: "%b %d" + time: + am: am + formats: + default: "%a, %d %b %Y %H:%M:%S %z" + long: "%B %d, %Y %H:%M" + short: "%d %b %H:%M" + pm: pm marten: db: field: diff --git a/src/marten/template/tag.cr b/src/marten/template/tag.cr index ab44749d5..8c22afaaf 100644 --- a/src/marten/template/tag.cr +++ b/src/marten/template/tag.cr @@ -37,6 +37,8 @@ module Marten register "if", If register "include", Include register "local_time", LocalTime + register "localize", Localize + register "l", Localize register "method_input", MethodInput register "reverse", Url register "spaceless", Spaceless diff --git a/src/marten/template/tag/localize.cr b/src/marten/template/tag/localize.cr new file mode 100644 index 000000000..be1b5079d --- /dev/null +++ b/src/marten/template/tag/localize.cr @@ -0,0 +1,101 @@ +require "./concerns/*" + +module Marten + module Template + module Tag + # The `localize` template tag. + # + # The `localize` template tag allows performing localization on values such as dates, numbers, and times + # within templates. It can take one mandatory argument (the value to localize) and an optional `format` + # keyword argument to specify a localization format. + # + # Usage examples: + # ``` + # {% localize created_at %} + # {% localize 100000 format: "short" %} + # ``` + # + # The `format` argument must match a key defined in the locale file. + # + # Optionally, the result of the localization can be assigned to a variable using `as` keyword: + # + # ``` + # {% localize created_at format: "short" as localized_date %} + # ``` + class Localize < Base + include CanExtractKwargs + include CanSplitSmartly + + @assigned_to : String? = nil + + def initialize(parser : Parser, source : String) + parts = split_smartly(source) + + if parts.size < 2 + raise Errors::InvalidSyntax.new("Malformed localize tag: at least one argument must be provided") + end + + @value_expression = FilterExpression.new(parts[1]) + + if parts.size > 2 && parts[-2] == "as" + @assigned_to = parts[-1] + kwargs_parts = parts[2...-2] + else + kwargs_parts = parts[2..] + end + + @kwargs = {} of String => FilterExpression + extract_kwargs(kwargs_parts.join(' ')).each do |key, value| + @kwargs[key] = FilterExpression.new(value) + end + end + + def render(context : Context) : String + value = @value_expression.resolve(context).raw + + if format_value = @kwargs["format"]? + format = format_value.resolve(context).to_s + end + + localized_value = localize_value(value, format) + + if @assigned_to.nil? + localized_value + else + context[@assigned_to.not_nil!] = localized_value + "" + end + end + + private def localize_value(value, format : String?) : String + case value + when Time, Float64, Int32, Int64 + valid_value = value.as(Float64 | Int32 | Int64 | Time) + format.nil? ? I18n.l(valid_value) : I18n.l(valid_value, format) + when Array(Marten::Template::Value) + if value.size != 3 + raise Errors::UnsupportedValue.new( + "Localization of dates requires an Array with exactly 3 elements, but received #{value.size}" + + " elements. Ensure the Array follows the format [year, month, day]." + ) + end + + if !value.all? { |element| element.raw.is_a?(Int32) } + types = value.map(&.raw.class).uniq! + raise Errors::UnsupportedType.new( + "Expected an Array with only Int32 elements, but found elements of types: #{types.join(", ")}. " + + "Please ensure all elements are Int32." + ) + end + date_value = {value[0].raw.as(Int32), value[1].raw.as(Int32), value[2].raw.as(Int32)} + format.nil? ? I18n.l(date_value) : I18n.l(date_value, format) + else + raise Errors::UnsupportedType.new( + "The `localize` tag only supports localization of Time or numeric values, but got #{value.class}" + ) + end + end + end + end + end +end