Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
goya committed Mar 16, 2017
1 parent 85e3383 commit 9ff3171
Show file tree
Hide file tree
Showing 12 changed files with 494 additions and 0 deletions.
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# A sample Gemfile
source "https://rubygems.org"
gemspec
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# i18n-asf

This library extends the [Ruby i18n library](https://github.com/svenfuchs/i18n) that ships with Rails to add support for ASF (Adobe Strings Format), Adobe's XML-based alternative to ZStrings for localizing application UI text. While i18n is the Rails default, and this library has been written with a Rails application in mind, you can also use this gem to add ASF support to any Ruby project.

## Setup

Just add this library to your `Gemfile`, either in addition to or instead of `i18n`:

```ruby
gem 'i18n-asf', :git => "git@fit.corp.adobe.com:ddemaree/i18n-asf.git"
```

Once that's done, you will be able to add `.asf` translation files to your app's `config/locales` directory alongside the `.yml` or `.rb` formats supported by the core library.

## Usage

The [Rails Internationalization Guide](http://guides.rubyonrails.org/i18n.html) provides a good overview of the i18n library and how to use it in a Rails project. Below you'll find a brief overview of ASF and how its structure maps to features and concepts in the core i18n framework.

ASF is a fairly lightweight XML grammar. Documents consist of an `asf` tag, which in turn can contain `str` (string) or `set` tags, both of which are identified by a `name` attribute. Both kinds of elements can have a `desc` element, used to describe the element's purpose and provide context for the translators. ASF strings can have multiple values, and both strings and sets can support arbitrary key-value metadata, though neither of these features are used here.

Here's an example of a valid ASF document:

```xml
<?xml version="1.0" encoding="utf-8" standalone="no" ?>
<!DOCTYPE asf SYSTEM "http://ns.adobe.com/asf/asf_1_0.dtd">
<asf version="1.0" locale="en_US" xmlns="http://ns.adobe.com/asf">
<str name="simple">
<desc>Example of a simple string definition</desc>
<val>Add to Kit</val>
</str>
<set name="browse">
<desc>Strings for the font browsing UI</desc>
<str name="font_count_heading">
<desc>Text for heading that includes the number of fonts on the page</desc>
<val>Showing <param name="font_count"/> fonts</val>
</str>
</set>
</asf>
```

ASF files are parsed into a Ruby hash structure, following the same general nesting and structural conventions the i18n library expects:

```ruby
:en => {
:simple => "Add to Kit",
:browse => {
:font_count_heading => "Showing %{font_count} fonts"
}
}
```

As of version 0.0.3, `i18n-asf` uses the `locale` attribute of the top-level `asf` tag (if present) to determine the locale for a given set of strings, so all the strings included in the example code above would be assigned to the `:en_US` locale.

If the `locale` attribute is not present, translations are assigned to a locale based on their filename (per the I18n library's default behavior), i.e. a file named `de.asf` will be assigned to the `:de` locale.

In your Rails app you can refer to these translations by a scoped identifier, and take advantage of I18n features such as interpolation, pluralization, date and number conversion, and so on.

```ruby
# This method is aliased as `t()` in Rails ERb views
I18n.translate("browse.font_count_heading", :count => 22)
#=> "Showing 22 fonts"
```

Calls to `translate` can take a `:scope` parameter, which can help DRY up your code:

```ruby
# These are all roughly equivalent
I18n.translate("index.font_count_heading", :count => 22, :scope => "browse")
I18n.translate("font_count_heading", :count => 22, :scope => "browse.index")
I18n.translate("font_count_heading", :count => 22, :scope => ["browse", "index"])
```
18 changes: 18 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
require "bundler/gem_tasks"

require 'rspec/core'
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec) do |spec|
spec.pattern = FileList['spec/**/*_spec.rb']
end

GIT_REMOTES = %w(origin github)

task :push_all do
GIT_REMOTES.each do |remote|
system "git push #{remote} master"
system "git push #{remote} master --tags"
end
end

task :default => :spec
28 changes: 28 additions & 0 deletions i18n-asf.gemspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# -*- encoding: utf-8 -*-
$:.push File.expand_path("../lib", __FILE__)

Gem::Specification.new do |s|
s.name = "i18n-asf"
s.version = "0.0.4"
s.authors = ["David Demaree"]
s.email = ["ddemaree@adobe.com"]
s.homepage = "https://typekit.com/"
s.summary = %q{Ruby I18n backend capable of reading Adobe Strings Format (ASF) XML files}
s.description = %q{See above.}

s.rubyforge_project = "i18n-asf"

s.files = `git ls-files`.split("\n")
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
s.require_paths = ["lib"]

s.add_runtime_dependency "i18n"
s.add_runtime_dependency "nokogiri"

s.add_development_dependency "rake"
s.add_development_dependency "rspec"
s.add_development_dependency "fivemat"
s.add_development_dependency "putsinator"
s.add_development_dependency "awesome_print"
end
4 changes: 4 additions & 0 deletions lib/i18n-asf.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
require 'i18n'
require 'i18n/backend/asf'

I18n::Backend::Simple.send(:include, I18n::Backend::ASF)
101 changes: 101 additions & 0 deletions lib/i18n/asf/processor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
require 'nokogiri'

module I18n
module ASF
class Processor
SKIP_TAG_NAMES = %w(dnt desc)

def self.shared
@processor ||= self.new
end

def self.process_document(document)
self.shared.process_document(document)
end

def process_document(document, output={})
if document.is_a?(String) || document.respond_to?(:read)
document = Nokogiri::XML(document) or raise "Document could not be parsed"
end

document.children.each do |node|
next if SKIP_TAG_NAMES.include?(node.name)

if node.name == 'str'
key, value = process_string_node(node)
output[key] = value
elsif node.name == 'set'
key = node[:name] or raise "Set missing name attribute at line #{string_node.line}"
output[key] = process_document(node, {})
elsif node.name == 'asf'
if _locale = node["locale"]
output["_locale"] = _locale
end

process_document(node, output)
end
end

output
end

def process_string_node(string_node)
key = string_node[:name] or raise "Str missing name attribute at line #{string_node.line}"
value = ""

string_node.at("val").children.each do |node|
value << process_content(node)
next
end

[key, value]
end

def named_parameter(node)
raise "Param tag missing `name' attribute at line #{node.line}" unless node[:name]
raise "Param name #{node[:name].inspect} is too short" unless node[:name].length >= 1
"%{#{node[:name]}}"
end

def process_content(node, output="")
current_tag = nil

if node.content.nil?
# Special case - likely HTML entity
output << node.to_s
elsif node.name == "param"
output << named_parameter(node)
elsif node.children.count == 0
output << node.content
else
# If element name isn't on the blacklist, turn it back to a string,
# append its opening tag, and remember its name so the tag can be
# closed later.
unless SKIP_TAG_NAMES.include?(node.name)
output << node.to_s.split(">").first + ">"
current_tag = node.name
end

node.children.each do |part|
if part.name == "param"
output << named_parameter(part)
elsif part.name == "dnt"
process_content(part, output)
elsif part.text?
output << part.content
else
raise "Unhandled node type #{node} on line #{node.line}"
end
end

if current_tag
output << "</#{current_tag}>"
current_tag = nil
end
end

output
end
end
end
end
25 changes: 25 additions & 0 deletions lib/i18n/backend/asf.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
require 'i18n/asf/processor'

module I18n
module Backend
module ASF

protected

def load_asf(filename)
data = parse(filename)
locale = data.delete("_locale") || get_locale_from_filename(filename)
{ locale => data }
end

def parse(filename)
::I18n::ASF::Processor.process_document File.read(filename)
end

def get_locale_from_filename(filename)
::File.basename(filename, '.asf').to_sym
end

end
end
end
76 changes: 76 additions & 0 deletions spec/fixtures/en.asf
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8" standalone="no" ?>
<!DOCTYPE asf SYSTEM "http://ns.adobe.com/asf/asf_1_0.dtd">
<asf version="1.0" locale="en-US" xmlns="http://ns.adobe.com/asf">
<str name="simple">
<desc>Example of a simple string definition</desc>
<val>Add to Kit</val>
</str>

<str name="with-html-entity">
<desc>String with HTML entity</desc>
<val>Adobe&reg; Photoshop&reg; Lightroom&trade;</val>
</str>

<str name="with-unicode">
<desc>String with Unicode characters</desc>
<val>Adobe® Photoshop® Lightroom™</val>
</str>

<str name="with-interpolation">
<desc>Translated string with param interpolation</desc>
<val>Invoice total: <param name="invoice_total" /></val>
</str>

<str name="with-dnt">
<desc>Translated string with portions that should not be translated</desc>
<val>Copyright <param name="year" /> <dnt>Typekit</dnt></val>
</str>

<str name="untranslated" translate="no">
<desc>Untranslated string; should still interpolate params and strip dnts</desc>
<val>Copyright &copy;<param name="year" /> <dnt>Typekit</dnt></val>
</str>

<str name="dnt-with-nested-param">
<desc>String with params nested inside dnt tag</desc>
<val>Hello! <dnt>Typekit is <param name="awesome" />!</dnt></val>
</str>

<str name="embedded-html">
<desc>String with embedded HTML; this should probably be avoided if at all possible but also supported if possible</desc>
<val>Your subscription is <b>Past Due since <param name="date" /></b></val>
</str>

<str name="encoded-html">
<desc>String with embedded HTML; this should probably be avoided if at all possible but also supported if possible</desc>
<val>Your subscription is &lt;b&gt;Past Due since <param name="date" />&lt;/b&gt;</val>
</str>

<!-- ASF language features we don't plan to use, but which the parser must support -->

<set name="backend">
<desc>Sample nested translation set</desc>
<str name="nested-string">
<desc>Example of a nested string</desc>
<val>I'm in the <dnt>backend</dnt></val>
</str>
</set>

<str name="propsets">
<desc>String with properties</desc>
<propset>
<prop>
<propkey>hello</propkey>
<propvalue>world</propvalue>
</prop>
</propset>
<val>Hello world!</val>
</str>

<str name="multi-value">
<desc>String with multiple values</desc>
<val>Hello world!</val>
<val accel="Cmd-Z" plat="mac">Goodnight Moon</val>
<val plat="mystery_meat">Salutations Brother</val>
</str>
</asf>
3 changes: 3 additions & 0 deletions spec/fixtures/sample.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
en:
hello_yaml: "Hello YAML!"
propsets: "From YAML"
49 changes: 49 additions & 0 deletions spec/i18n_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
require "spec_helper"

describe "Ruby I18N" do

let(:fixtures_path) { Pathname.new(File.expand_path("../fixtures", __FILE__)) }

before(:all) do
I18n.load_path << fixtures_path.join("en.asf")
I18n.load_path << fixtures_path.join("sample.yml")

# The ASF file is en-US, YAML is just 'en'; ASF translations will have
# precedence but YAML translations will be available as fallbacks.
# In practice, ASF translations should be :en by default, with regional
# variations (en-GB, en-AU) used as overrides to cover edge cases, though
# wherever possible the standard English versions should be written so
# as to be appropriate for all English-speaking regions.
# U.S. spelling or semantic conventions, e.g. 'color' not 'colour', are
# acceptable.
I18n.default_locale = :en
I18n.locale = :"en-US"
end

describe "loading ASF translations" do
let(:translations) { I18n.backend.send(:translations) }

before do
I18n.backend.send(:init_translations)
end

it "reads locale from the ASF markup if present" do
translations[:"en-US"][:simple].should == "Add to Kit"
end
end

specify "using translations from ASF" do
I18n.t("simple").should == "Add to Kit"
I18n.t("with-interpolation", :invoice_total => "$49.99").should == "Invoice total: $49.99"
I18n.t("backend.nested-string").should == "I'm in the backend"
end

describe "using ASF and YAML strings together" do
it "can use YAML for fallbacks" do
I18n.t("hello_yaml").should == "Hello YAML!"
end
it "prefers the current locale if the same string is present in both" do
I18n.t("propsets").should == "Hello world!"
end
end
end
Loading

0 comments on commit 9ff3171

Please sign in to comment.