Skip to content
This repository has been archived by the owner on Jun 1, 2023. It is now read-only.

[Feature] Add l10n support to CheckoutUiExtension #2009

Merged
merged 1 commit into from
Feb 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
From version 2.6.0, the sections in this file adhere to the [keep a changelog](https://keepachangelog.com/en/1.0.0/) specification.

## [Unreleased]
### Added
* [#2009](https://github.com/Shopify/shopify-cli/pull/2009): Add localization support for Checkout Extensions

## Version 2.11.1
### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,24 @@ module Extension
module Models
module SpecificationHandlers
class CheckoutUiExtension < Default
L10N_ERROR_PREFIX = "core.extension.push.checkout_ui_extension.localization.error"
L10N_FILE_SIZE_LIMIT = 16 * 1024 # 16kb
L10N_BUNDLE_SIZE_LIMIT = 256 * 1024 # 256kb
ginmrt marked this conversation as resolved.
Show resolved Hide resolved
ginmrt marked this conversation as resolved.
Show resolved Hide resolved
LOCALE_CODE_FORMAT = %r{
\A
(?<language>[a-zA-Z]{2,3}) # Language tag
(?:
-
(?<region>[a-zA-Z]{2}) # Optional region subtag
)?
\z}x
PERMITTED_CONFIG_KEYS = [:extension_points, :metafields, :name]

def config(context)
{
**Features::ArgoConfig.parse_yaml(context, PERMITTED_CONFIG_KEYS),
**argo.config(context, include_renderer_version: false),
**localization(context),
}
end

Expand All @@ -22,6 +34,108 @@ def build_resource_url(context:, shop:)
return unless product
format("/cart/%<variant_id>d:%<quantity>d", variant_id: product.variant_id, quantity: 1)
end

private

def localization(context)
ginmrt marked this conversation as resolved.
Show resolved Hide resolved
Dir.chdir(context.root) do
locale_filenames = Dir["locales/*"].select { |filename| valid_l10n_file?(filename) }
# Localization is optional
return {} if locale_filenames.empty?

validate_total_size(locale_filenames)
default_locale = single_default_locale(locale_filenames)

locale_filenames.map do |filename|
locale = basename_for_locale_filename(filename)
[locale.to_sym, Base64.strict_encode64(File.read(filename, mode: "rt", encoding: "UTF-8").strip)]
end
.yield_self do |encoded_files_by_locale|
{
localization: {
default_locale: default_locale,
translations: encoded_files_by_locale.to_h,
},
}
end
end
end

def validate_total_size(locale_filenames)
total_size = locale_filenames.sum { |filename| File.size(filename) }
if total_size > L10N_BUNDLE_SIZE_LIMIT
raise(
ShopifyCLI::Abort,
ShopifyCLI::Context.message(
"#{L10N_ERROR_PREFIX}.bundle_too_large",
CLI::Kit::Util.to_filesize(L10N_BUNDLE_SIZE_LIMIT)
)
)
end
end

def single_default_locale(locale_filenames)
default_locale_matches = locale_filenames.grep(/default/)
if default_locale_matches.size != 1
raise(ShopifyCLI::Abort, ShopifyCLI::Context.message("#{L10N_ERROR_PREFIX}.single_default_locale"))
end
basename_for_locale_filename(default_locale_matches.first)
end

def valid_l10n_file?(filename)
return false unless File.file?(filename)
return false unless File.dirname(filename) == "locales"

validate_file_extension(filename)
validate_file_locale_code(filename)
validate_file_size(filename)
validate_file_not_empty(filename)

true
end

def validate_file_extension(filename)
if File.extname(filename) != ".json"
raise(
ShopifyCLI::Abort, ShopifyCLI::Context.message("#{L10N_ERROR_PREFIX}.invalid_file_extension", filename)
)
end
end

def validate_file_locale_code(filename)
unless valid_locale_code?(basename_for_locale_filename(filename))
raise(
ShopifyCLI::Abort, ShopifyCLI::Context.message("#{L10N_ERROR_PREFIX}.invalid_locale_code", filename)
)
end
end

def validate_file_size(filename)
if File.size(filename) > L10N_FILE_SIZE_LIMIT
raise(
ShopifyCLI::Abort,
ShopifyCLI::Context.message(
"#{L10N_ERROR_PREFIX}.file_too_large",
filename,
CLI::Kit::Util.to_filesize(L10N_FILE_SIZE_LIMIT)
)
)
end
end

def validate_file_not_empty(filename)
if File.zero?(filename)
raise(ShopifyCLI::Abort, ShopifyCLI::Context.message("#{L10N_ERROR_PREFIX}.file_empty", filename))
end
end

def valid_locale_code?(locale_code)
LOCALE_CODE_FORMAT.match?(locale_code)
end

def basename_for_locale_filename(filename)
File.basename(File.basename(filename, ".json"), ".default")
end
end
end
end
Expand Down
18 changes: 18 additions & 0 deletions lib/shopify_cli/messages/messages.rb
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,24 @@ module Messages
HELP
},
},
extension: {
push: {
checkout_ui_extension: {
localization: {
error: {
bundle_too_large: "Total size of all locale files must be less than %s.",
file_empty: "Locale file `%s` is empty.",
file_too_large: "Locale file `%s` too large; size must be less than %s.",
invalid_file_extension: "Invalid locale filename: `%s`; only .json files are allowed.",
invalid_locale_code: "Invalid locale filename: `%s`; locale code should be 2 or 3 letters,"\
" optionally followed by a two-letter region code, e.g. `fr-CA`.",
single_default_locale: "There must be one and only one locale identified as the default locale,"\
" e.g. `en.default.json`",
},
},
},
},
},
error_reporting: {
unhandled_error: {
message: "{{x}} {{red:An unexpected error occured.}}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@ module Models
module SpecificationHandlers
class CheckoutUiExtensionTest < MiniTest::Test
include ExtensionTestHelpers
VALID_L10N_FILENAMES = [
"FR-CA.json", # case-insensitive
"fr-CA.json", # case-insensitive
"FR-ca.json", # case-insensitive
"fr-ca.json", # case-insensitive
"fr.json", # language tag = 2 characters
"dsb.json", # language tag = 3 characters
"dsb-ab.json", # language tag = 3 characters + region code = 2 characters
]

INVALID_L10N_FILENAMES = [
"french.json", # language tag > 3 characters
"f.json", # language tag < 2 characters
"fren-ca.json", # language tag > 3 characters + region code
"f-ca.json", # language tag < 2 characters + region code
"fr-can.json", # region code > 2 characters
"fr-c.json", # region code < 2 characters
"fr.defalt.json", # typo in default
"11-ca.json", # numbers in language tag
"fr-11.json", # numbers in region code
"fr.ca.json", # dot instead of dash
"fr_ca.json", # underscore instead of dash
]

L10N_ERROR_PREFIX = "core.extension.push.checkout_ui_extension.localization.error"

def setup
super
Expand All @@ -20,6 +45,11 @@ def setup

@identifier = "CHECKOUT_UI_EXTENSION"
@checkout_ui_extension = specifications[@identifier]
@context.root = Dir.mktmpdir
end

def teardown
FileUtils.remove_dir(@context.root)
end

def test_create_uses_standard_argo_create_implementation
Expand Down Expand Up @@ -90,6 +120,131 @@ def test_build_resource_url_nil_safety
resource_url = @checkout_ui_extension.build_resource_url(context: @context, shop: shop)
assert_nil resource_url
end

def test_l10n_files_encoded
en_content = '{"laugh": "lol"}'
fr_content = '{"laugh": "mdr"}'
ginmrt marked this conversation as resolved.
Show resolved Hide resolved
write("locales/fr.default.json", fr_content)
write("locales/en.json", en_content)

expected = {
localization: {
translations: {
fr: Base64.strict_encode64(fr_content),
en: Base64.strict_encode64(en_content),
},
default_locale: "fr",
},
}
assert_equal(expected, @checkout_ui_extension.config(@context))
end

def test_l10n_files_at_root_are_ignored
write("locales/fr.default.json", "{}")
write("wut.json", "{}")
config = @checkout_ui_extension.config(@context)
refute_includes(config[:localization][:translations], "wut")
end

def test_non_locale_files_are_ignored
assert_nothing_raised do
write("invalid-folder/fr.json", "{}")
config = @checkout_ui_extension.config(@context)
refute_includes(config, "localization")
end
end

def test_l10n_multiple_defaults
write("locales/fr.default.json", "{}")
write("locales/en.default.json", "{}")
assert_raises ShopifyCLI::Abort, "#{L10N_ERROR_PREFIX}.single_default_locale" do
@checkout_ui_extension.config(@context)
end
end

def test_l10n_no_defaults
write("locales/fr.json", "{}")
assert_raises ShopifyCLI::Abort, "#{L10N_ERROR_PREFIX}.single_default_locale" do
@checkout_ui_extension.config(@context)
end
end

def test_l10n_file_extension
write("locales/fr.txt", "hello")
assert_raises ShopifyCLI::Abort, "#{L10N_ERROR_PREFIX}.invalid_file_extension" do
@checkout_ui_extension.config(@context)
end
end

def test_l10n_empty_file
write("locales/en.default.json", "")
assert_raises ShopifyCLI::Abort, "#{L10N_ERROR_PREFIX}.file_empty" do
@checkout_ui_extension.config(@context)
end
end

def test_l10n_max_filesize
stub_const(CheckoutUiExtension, :L10N_FILE_SIZE_LIMIT, 50) do
write("locales/fr.json", "1" * 60)
assert_raises ShopifyCLI::Abort, "#{L10N_ERROR_PREFIX}.file_too_large" do
@checkout_ui_extension.config(@context)
end
end
end

def test_l10n_sum_max_filesize
stub_const(CheckoutUiExtension, :L10N_BUNDLE_SIZE_LIMIT, 50) do
write("locales/fr.json", "1" * 30)
write("locales/en.default.json", "1" * 30)
assert_raises ShopifyCLI::Abort, "#{L10N_ERROR_PREFIX}.bundle_too_large" do
@checkout_ui_extension.config(@context)
end
end
end

VALID_L10N_FILENAMES.each do |filename|
define_method("test_valid_l10n_filename_#{filename}") do
write("locales/en.default.json", "{}")
write("locales/#{filename}", "{}")

assert_nothing_raised do
@checkout_ui_extension.config(@context)
end
end
end

INVALID_L10N_FILENAMES.each do |filename|
define_method("test_invalid_l10n_filename_#{filename}") do
write("locales/en.default.json", "{}")
write("locales/#{filename}", "{}")
assert_raises ShopifyCLI::Abort, "#{L10N_ERROR_PREFIX}.invalid_locale_code" do
@checkout_ui_extension.config(@context)
end
end
end

private

def write(filename, content, mode: "w", encoding: "utf-8")
filename = File.join(@context.root, filename)
FileUtils.mkdir_p(File.dirname(filename))
File.write(filename, content, mode: mode, encoding: encoding)
end

def stub_const(object, name, value)
original = object.const_get(name)
silent_const_set(object, name, value)
yield
ensure
silent_const_set(object, name, original)
end

def silent_const_set(object, name, value)
old_verbose = $VERBOSE
$VERBOSE = nil
object.const_set(name, value)
$VERBOSE = old_verbose
end
end
end
end
Expand Down