From d1fcbe69a3bb3f8cd2c8d86b11df30015cff72a9 Mon Sep 17 00:00:00 2001 From: vendethiel Date: Thu, 3 Jun 2021 16:04:43 +0200 Subject: [PATCH 1/3] Implement Cro::WebApp::I18N --- lib/Cro/WebApp/I18N.pm6 | 30 ++++++++++++++++++++++++++++ lib/Cro/WebApp/Template/Builtins.pm6 | 9 +++++++++ t/i18n.t | 28 ++++++++++++++++++++++++++ t/resources/test.po | 2 ++ t/test-data/i18n.crotmp | 1 + 5 files changed, 70 insertions(+) create mode 100644 lib/Cro/WebApp/I18N.pm6 create mode 100644 t/i18n.t create mode 100644 t/resources/test.po create mode 100644 t/test-data/i18n.crotmp diff --git a/lib/Cro/WebApp/I18N.pm6 b/lib/Cro/WebApp/I18N.pm6 new file mode 100644 index 0000000..d7c49c8 --- /dev/null +++ b/lib/Cro/WebApp/I18N.pm6 @@ -0,0 +1,30 @@ +use Cro::HTTP::Router :plugin; +use POFile; + +my $plugin-key = router-plugin-register('cro-webapp-i18n'); +my $prefix-key = router-plugin-register('cro-webapp-i18n-prefix'); + +sub load-translation-file(Str $prefix, $file) is export { + router-plugin-add-config($plugin-key, $prefix => POFile.load($file)); +} + +sub _-prefix(Str $prefix) is export { + router-plugin-add-config($prefix-key, $prefix); +} + +proto sub _(|) is export {*} + +multi sub _(Str $key) { + my @prefixes = router-plugin-get-innermost-configs($prefix-key) + or die "No prefix configured, did you mean to configure _-prefix or use the long form of _?"; + _(@prefixes[*-1], $key); +} + +multi sub _(Str $prefix, Str $key) { + my %config = router-plugin-get-configs($plugin-key); + with %config{$prefix} { + ($_{$key} // die "No key $key in $prefix").msgstr; + } else { + die "No such translation file: $prefix"; + } +} \ No newline at end of file diff --git a/lib/Cro/WebApp/Template/Builtins.pm6 b/lib/Cro/WebApp/Template/Builtins.pm6 index 3a1c1ea..a188049 100644 --- a/lib/Cro/WebApp/Template/Builtins.pm6 +++ b/lib/Cro/WebApp/Template/Builtins.pm6 @@ -1,3 +1,5 @@ +use Cro::WebApp::I18N; + class X::Cro::WebApp::Template::XSS is Exception { has Str $.content is required; method message() { @@ -15,3 +17,10 @@ sub __TEMPLATE_SUB__HTML(Str() $html) is export { sub __TEMPLATE_SUB__HTML-AND-JAVASCRIPT(Str() $html) is export { $html } + +multi sub __TEMPLATE_SUB___(Str $key) is export { + _($key); +} +multi sub __TEMPLATE_SUB___(Str $prefix, Str $key) is export { + _($prefix, $key); +} \ No newline at end of file diff --git a/t/i18n.t b/t/i18n.t new file mode 100644 index 0000000..807778f --- /dev/null +++ b/t/i18n.t @@ -0,0 +1,28 @@ +use Cro::WebApp::I18N; +use Cro::HTTP::Router; +use Cro::HTTP::Client; +use Cro::HTTP::Server; +use Cro::WebApp::Template; +use Test; + +my constant TEST_PORT = 30210; + +template-location $*PROGRAM.parent.add('test-data'); + +my $application = route { + load-translation-file('main', 't/resources/test.po'); + _-prefix 'main'; + get -> 'render' { + is 'b', _('main', 'a'); + template 'i18n.crotmp'; + } +} +my $server = Cro::HTTP::Server.new(:$application, :host('localhost'), :port(TEST_PORT)); +$server.start; +LEAVE try $server.stop; +my $client = Cro::HTTP::Client.new(base-uri => "http://localhost:{ TEST_PORT }", :cookie-jar); + +my $render-response; +lives-ok { $render-response = await $client.get("/render") }, + 'Can render a form in a template'; +is await($render-response.body-text), 'b'; \ No newline at end of file diff --git a/t/resources/test.po b/t/resources/test.po new file mode 100644 index 0000000..c404c0c --- /dev/null +++ b/t/resources/test.po @@ -0,0 +1,2 @@ +msgid "a" +msgstr "b" \ No newline at end of file diff --git a/t/test-data/i18n.crotmp b/t/test-data/i18n.crotmp new file mode 100644 index 0000000..3e8d5e3 --- /dev/null +++ b/t/test-data/i18n.crotmp @@ -0,0 +1 @@ +<&_('a')> \ No newline at end of file From 95404e9481b51b84282769a74a1eaddefafe924c Mon Sep 17 00:00:00 2001 From: vendethiel Date: Thu, 3 Jun 2021 23:57:57 +0200 Subject: [PATCH 2/3] Form support for I18N --- lib/Cro/WebApp/Form.pm6 | 19 ++++++++++++++++++- lib/Cro/WebApp/I18N.pm6 | 23 +++++++++++++---------- lib/Cro/WebApp/Template/Builtins.pm6 | 11 ++--------- t/i18n.t | 21 ++++++++++++++++++--- t/resources/test.po | 5 ++++- t/test-data/i18n-_.crotmp | 2 ++ t/test-data/i18n-form.crotmp | 1 + t/test-data/i18n.crotmp | 1 - 8 files changed, 58 insertions(+), 25 deletions(-) create mode 100644 t/test-data/i18n-_.crotmp create mode 100644 t/test-data/i18n-form.crotmp delete mode 100644 t/test-data/i18n.crotmp diff --git a/lib/Cro/WebApp/Form.pm6 b/lib/Cro/WebApp/Form.pm6 index 3e64264..ffdd5a7 100644 --- a/lib/Cro/WebApp/Form.pm6 +++ b/lib/Cro/WebApp/Form.pm6 @@ -1,9 +1,11 @@ use Cro::HTTP::Body; use Cro::HTTP::MultiValue; +use Cro::WebApp::I18N; #| A role to be mixed in to Attribute to hold extra form-related properties. my role FormProperties { has $.webapp-form-label is rw; + has $.webapp-form-i18n-label is rw; has $.webapp-form-placeholder is rw; has $.webapp-form-help is rw; has Str $.webapp-form-type is rw; @@ -30,6 +32,12 @@ multi trait_mod:(Attribute:D $attr, :$label! --> Nil) is export { $attr.webapp-form-label = $label; } +#| Set the i18n key to be used for this attribute (falls back to label). +multi trait_mod:(Attribute:D $attr, :$i18n-label! --> Nil) is export { + ensure-attr-state($attr); + $attr.webapp-form-i18n-label = $i18n-label; +} + #| Provide placeholder text for a form field. multi trait_mod:(Attribute:D $attr, :$placeholder! --> Nil) is export { ensure-attr-state($attr); @@ -521,7 +529,7 @@ role Cro::WebApp::Form { } method !calculate-label($attr) { - with $attr.?webapp-form-label { + my $label = do with $attr.?webapp-form-label { # Explicitly provided label $_ } @@ -531,6 +539,12 @@ role Cro::WebApp::Form { @words[0] .= tclc; @words.join(' ') } + + with $attr.?webapp-form-i18n-label { + _($_, prefix => self.i18n-prefix, default => $label) + } else { + $label + } } #| Add validation errors to a control. @@ -747,6 +761,9 @@ role Cro::WebApp::Form { method add-validation-error($message --> Nil) { $!validation-state.add-custom-error($message); } + + #| Defines the prefix to use for translations using this form + method i18n-prefix(--> Str) { Str:U } } #| Take the submitted data in the request body and parse it into a form object of diff --git a/lib/Cro/WebApp/I18N.pm6 b/lib/Cro/WebApp/I18N.pm6 index d7c49c8..2ca6886 100644 --- a/lib/Cro/WebApp/I18N.pm6 +++ b/lib/Cro/WebApp/I18N.pm6 @@ -12,18 +12,21 @@ sub _-prefix(Str $prefix) is export { router-plugin-add-config($prefix-key, $prefix); } -proto sub _(|) is export {*} - -multi sub _(Str $key) { - my @prefixes = router-plugin-get-innermost-configs($prefix-key) - or die "No prefix configured, did you mean to configure _-prefix or use the long form of _?"; - _(@prefixes[*-1], $key); -} - -multi sub _(Str $prefix, Str $key) { +sub _(Str $key, Str :$prefix is copy, Str :$default) is export { + without $prefix { + my @prefixes = router-plugin-get-innermost-configs($prefix-key) + or die "No prefix configured, did you mean to configure _-prefix or use the long form of _?"; + $prefix = @prefixes[*-1]; + } my %config = router-plugin-get-configs($plugin-key); with %config{$prefix} { - ($_{$key} // die "No key $key in $prefix").msgstr; + with $_{$key} { + .msgstr; + } orwith $default { + $default + } else { + die "No key $key in $prefix"; + } } else { die "No such translation file: $prefix"; } diff --git a/lib/Cro/WebApp/Template/Builtins.pm6 b/lib/Cro/WebApp/Template/Builtins.pm6 index a188049..b787a53 100644 --- a/lib/Cro/WebApp/Template/Builtins.pm6 +++ b/lib/Cro/WebApp/Template/Builtins.pm6 @@ -14,13 +14,6 @@ sub __TEMPLATE_SUB__HTML(Str() $html) is export { $html } -sub __TEMPLATE_SUB__HTML-AND-JAVASCRIPT(Str() $html) is export { - $html -} - -multi sub __TEMPLATE_SUB___(Str $key) is export { - _($key); -} -multi sub __TEMPLATE_SUB___(Str $prefix, Str $key) is export { - _($prefix, $key); +multi sub __TEMPLATE_SUB___(Str $key, Str :$prefix) is export { + _($key, :$prefix); } \ No newline at end of file diff --git a/t/i18n.t b/t/i18n.t index 807778f..307f92d 100644 --- a/t/i18n.t +++ b/t/i18n.t @@ -2,6 +2,7 @@ use Cro::WebApp::I18N; use Cro::HTTP::Router; use Cro::HTTP::Client; use Cro::HTTP::Server; +use Cro::WebApp::Form; use Cro::WebApp::Template; use Test; @@ -9,12 +10,20 @@ my constant TEST_PORT = 30210; template-location $*PROGRAM.parent.add('test-data'); +my class I18NAwareForm is Cro::WebApp::Form { + has $.name is rw is i18n-label('name-field'); +} + my $application = route { load-translation-file('main', 't/resources/test.po'); _-prefix 'main'; get -> 'render' { - is 'b', _('main', 'a'); - template 'i18n.crotmp'; + is 'b', _('a', :prefix('main')); + template 'i18n-_.crotmp'; + } + + get -> 'form' { + template 'i18n-form.crotmp', { foo => I18NAwareForm.empty }; } } my $server = Cro::HTTP::Server.new(:$application, :host('localhost'), :port(TEST_PORT)); @@ -24,5 +33,11 @@ my $client = Cro::HTTP::Client.new(base-uri => "http://localhost:{ TEST_PORT }", my $render-response; lives-ok { $render-response = await $client.get("/render") }, + 'Can use _ in a template'; +ok $render-response.defined; +is await($render-response.body-text), "b\nb"; + +lives-ok { $render-response = await $client.get("/form") }, 'Can render a form in a template'; -is await($render-response.body-text), 'b'; \ No newline at end of file +ok $render-response.defined; +ok await($render-response.body-text) ~~ /'Your Name'/; \ No newline at end of file diff --git a/t/resources/test.po b/t/resources/test.po index c404c0c..e527099 100644 --- a/t/resources/test.po +++ b/t/resources/test.po @@ -1,2 +1,5 @@ msgid "a" -msgstr "b" \ No newline at end of file +msgstr "b" + +msgid "name-field" +msgstr "Your Name" \ No newline at end of file diff --git a/t/test-data/i18n-_.crotmp b/t/test-data/i18n-_.crotmp new file mode 100644 index 0000000..90fe834 --- /dev/null +++ b/t/test-data/i18n-_.crotmp @@ -0,0 +1,2 @@ +<&_('a')> +<&_('a', :prefix('main'))> \ No newline at end of file diff --git a/t/test-data/i18n-form.crotmp b/t/test-data/i18n-form.crotmp new file mode 100644 index 0000000..8583032 --- /dev/null +++ b/t/test-data/i18n-form.crotmp @@ -0,0 +1 @@ +<&form(.foo)> \ No newline at end of file diff --git a/t/test-data/i18n.crotmp b/t/test-data/i18n.crotmp deleted file mode 100644 index 3e8d5e3..0000000 --- a/t/test-data/i18n.crotmp +++ /dev/null @@ -1 +0,0 @@ -<&_('a')> \ No newline at end of file From 0923a9a2ab54bcc905c087ebef74328ffb8d61de Mon Sep 17 00:00:00 2001 From: vendethiel Date: Mon, 7 Jun 2021 17:43:58 +0200 Subject: [PATCH 3/3] Second iteration of the I18N module --- lib/Cro/WebApp/I18N.pm6 | 77 +++++++++++++++++++++++++------- t/i18n.t | 53 ++++++++++++++-------- t/resources/main-fr.po | 2 + t/resources/{test.po => main.po} | 0 4 files changed, 98 insertions(+), 34 deletions(-) create mode 100644 t/resources/main-fr.po rename t/resources/{test.po => main.po} (100%) diff --git a/lib/Cro/WebApp/I18N.pm6 b/lib/Cro/WebApp/I18N.pm6 index 2ca6886..6b593a4 100644 --- a/lib/Cro/WebApp/I18N.pm6 +++ b/lib/Cro/WebApp/I18N.pm6 @@ -1,33 +1,80 @@ -use Cro::HTTP::Router :plugin; +use Cro::HTTP::Router :DEFAULT, :plugin; use POFile; -my $plugin-key = router-plugin-register('cro-webapp-i18n'); +my $plugin-key = router-plugin-register('cro-webapp-i18n-files'); my $prefix-key = router-plugin-register('cro-webapp-i18n-prefix'); -sub load-translation-file(Str $prefix, $file) is export { - router-plugin-add-config($plugin-key, $prefix => POFile.load($file)); +my class TranslationFile { + has Str:D $.prefix is required; + has POFile:D $.file is required; + has Str @.languages; } +#| Load a translation file and store it with a given prefix and a given (set of) language +sub load-translation-file(Str:D $prefix, $file, :language(:languages(@languages))) is export { + my $pofile = POFile.load($file); + my $translation-file = TranslationFile.new(:@languages, :$prefix, file => $pofile); + router-plugin-add-config($plugin-key, $translation-file); +} + +#| Configure the default prefix `_` should use. +#| This is useful for reducing duplication, especially in templates. sub _-prefix(Str $prefix) is export { router-plugin-add-config($prefix-key, $prefix); } +#| Install a language selection handler. +#| That handler will receive a list of languages accepted by the client (from the Accept-Language header), +#| and should return a language that will be used to filter against the loaded translation files. +sub select-language(Callable $fn) is export { + # XXX We might register multiple `before-matched`, which is LTA + before-matched { + my @languages = get-languages(request.header('accept-language')); + request.annotations = $fn(@languages); + } +} + +#| Look up key and return its associated translation sub _(Str $key, Str :$prefix is copy, Str :$default) is export { without $prefix { my @prefixes = router-plugin-get-innermost-configs($prefix-key) - or die "No prefix configured, did you mean to configure _-prefix or use the long form of _?"; - $prefix = @prefixes[*-1]; + or die "No prefix configured, did you forget to call `_-prefix` or pass the prefix to _?"; + $prefix = @prefixes[*- 1]; } - my %config = router-plugin-get-configs($plugin-key); - with %config{$prefix} { - with $_{$key} { - .msgstr; - } orwith $default { - $default - } else { - die "No key $key in $prefix"; + my $language = guess-language; + my %files = router-plugin-get-configs($plugin-key) + .grep(*.prefix eq $prefix) + .classify({ match-language(.languages, $language) }); + for |(%files{"1"} // ()), |(%files{"2"} // ()), |(%files{"3"} // ()) { + with .file{$key} { + return .msgstr; } + } + $default // die "No key $key in $prefix"; +} + +sub match-language(Str @languages, Str $accept --> Int) { + if +@languages && $accept.defined { + return 1 if any(@languages) eq $accept; + return 2 if $accept ~~ /^@languages'-'/; + # XXX is this fuzzy matching really necessary + return 4 + } else { + return 3 + } +} + +sub guess-language(--> Str) { + try { request.annotations } // Str +} + +# TODO move this to Request +sub get-languages($header) { + with $header { + # TODO q sort + # TODO move this to a request method + $header.split(',')>>.trim.map(*.split(';')[0].trim) } else { - die "No such translation file: $prefix"; + () } } \ No newline at end of file diff --git a/t/i18n.t b/t/i18n.t index 307f92d..553a82a 100644 --- a/t/i18n.t +++ b/t/i18n.t @@ -14,30 +14,45 @@ my class I18NAwareForm is Cro::WebApp::Form { has $.name is rw is i18n-label('name-field'); } -my $application = route { - load-translation-file('main', 't/resources/test.po'); +is get-response(route { + load-translation-file('main', 't/resources/main.po'); _-prefix 'main'; + get -> 'render' { is 'b', _('a', :prefix('main')); template 'i18n-_.crotmp'; } +}), "b\nb"; + +ok get-response(route { + load-translation-file('main', 't/resources/main.po'); + _-prefix 'main'; - get -> 'form' { + get -> 'render' { template 'i18n-form.crotmp', { foo => I18NAwareForm.empty }; } -} -my $server = Cro::HTTP::Server.new(:$application, :host('localhost'), :port(TEST_PORT)); -$server.start; -LEAVE try $server.stop; -my $client = Cro::HTTP::Client.new(base-uri => "http://localhost:{ TEST_PORT }", :cookie-jar); - -my $render-response; -lives-ok { $render-response = await $client.get("/render") }, - 'Can use _ in a template'; -ok $render-response.defined; -is await($render-response.body-text), "b\nb"; - -lives-ok { $render-response = await $client.get("/form") }, - 'Can render a form in a template'; -ok $render-response.defined; -ok await($render-response.body-text) ~~ /'Your Name'/; \ No newline at end of file +}) ~~ /'Your Name'/; + +is get-response(route { + # XXX We currently fuzzy-match `en` and `en-XX`, should we really? + load-translation-file('main', 't/resources/main.po', :language); + load-translation-file('main', 't/resources/main-fr.po', :language); + _-prefix 'main'; + select-language -> @ { 'fr' } + + get -> 'render' { + template 'i18n-_.crotmp'; + } +}), "b mais en français\nb mais en français"; + +sub get-response($application) { + my $server = Cro::HTTP::Server.new(:$application, :host('localhost'), :port(TEST_PORT)); + $server.start; + LEAVE try $server.stop; + my $client = Cro::HTTP::Client.new(base-uri => "http://localhost:{ TEST_PORT }", :cookie-jar); + + my $render-response; + lives-ok { $render-response = await $client.get("/render") }; + ok $render-response.defined; + return await $render-response.body-text; +} \ No newline at end of file diff --git a/t/resources/main-fr.po b/t/resources/main-fr.po new file mode 100644 index 0000000..cb4d763 --- /dev/null +++ b/t/resources/main-fr.po @@ -0,0 +1,2 @@ +msgid "a" +msgstr "b mais en français" \ No newline at end of file diff --git a/t/resources/test.po b/t/resources/main.po similarity index 100% rename from t/resources/test.po rename to t/resources/main.po