Skip to content

Commit

Permalink
Add localize template tag (#277)
Browse files Browse the repository at this point in the history
* Add `localize` template tag

* Fix tag spec

* Improve test description

* Fix findings

* Add documentation

* Update docs/docs/templates/reference/tags.md

* Update docs/docs/templates/reference/tags.md

* Update docs/docs/templates/reference/tags.md

* Update docs/docs/templates/reference/tags.md

---------

Co-authored-by: Morgan Aubert <[email protected]>
  • Loading branch information
treagod and ellmetha authored Nov 28, 2024
1 parent aadba15 commit 736c396
Show file tree
Hide file tree
Showing 8 changed files with 304 additions and 2 deletions.
23 changes: 23 additions & 0 deletions docs/docs/templates/reference/tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
130 changes: 130 additions & 0 deletions spec/marten/template/tag/localize_spec.cr
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion spec/marten/template/tag_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions spec/test_project/locales/en.yml
Original file line number Diff line number Diff line change
@@ -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}!"
Expand Down
20 changes: 19 additions & 1 deletion src/marten/locales/de.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -86,4 +104,4 @@ de:
invalid: "Geben Sie eine gültige URL ein."
uuid:
errors:
invalid: "Geben Sie eine gültige UUID ein."
invalid: "Geben Sie eine gültige UUID ein."
18 changes: 18 additions & 0 deletions src/marten/locales/en.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/marten/template/tag.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions src/marten/template/tag/localize.cr
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 736c396

Please sign in to comment.