diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..7bd39f95 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# default configuration +[*] +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +# Unix-style newlines with a newline ending every file +end_of_line = lf + +# Set default charset +charset = utf-8 + +# Tab indentation (no size specified) +[Makefile] +indent_style = tab + +[*.{md,markdown}] +trim_trailing_whitespace = false diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..2ae23928 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +--- +github: ["fnando"] +custom: ["https://paypal.me/nandovieira/🍕"] diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 00000000..274b66d7 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,111 @@ +name: Tests + +on: + pull_request: + branches: + - master + paths-ignore: + - 'README.md' + push: + branches: + - master + paths-ignore: + - 'README.md' + +jobs: + ruby_unit_tests: + name: Ruby Unit Tests + if: "contains(github.event.commits[0].message, '[ci skip]') == false" + strategy: + fail-fast: false + matrix: + os: + - ubuntu + ruby: + - 2.4 + - 2.5 + - 2.6 + - 2.7 + gemfile: + - gemfiles/i18n_0_6.gemfile + - gemfiles/i18n_0_7.gemfile + - gemfiles/i18n_0_8.gemfile + - gemfiles/i18n_0_9.gemfile + - gemfiles/i18n_1_0.gemfile + - gemfiles/i18n_1_1.gemfile + - gemfiles/i18n_1_2.gemfile + - gemfiles/i18n_1_3.gemfile + - gemfiles/i18n_1_4.gemfile + - gemfiles/i18n_1_5.gemfile + - gemfiles/i18n_1_6.gemfile + - gemfiles/i18n_1_7.gemfile + - gemfiles/i18n_1_8.gemfile + allow_failures: + - false + include: + - os: ubuntu + ruby: ruby-head + gemfile: gemfiles/i18n_1_8.gemfile + allow_failures: true + env: + BUNDLE_GEMFILE: "${{ matrix.gemfile }}" + BUNDLE_PATH: "./vendor/bundle" + ALLOW_FAILURES: "${{ matrix.allow_failures }}" + runs-on: ${{ matrix.os }}-latest + continue-on-error: ${{ endsWith(matrix.ruby, 'head') || matrix.ruby == 'debug' }} + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + - uses: actions/cache@v2 + with: + path: gemfiles/vendor/bundle + key: ${{ runner.os }}-gems-${{ matrix.ruby }}-${{ matrix.gemfile }}-${{ github.ref }}-${{ github.sha }}-v2 + restore-keys: | + ${{ runner.os }}-gems--${{ matrix.ruby }}-${{ matrix.gemfile }}-${{ github.ref }}- + ${{ runner.os }}-gems--${{ matrix.ruby }}-${{ matrix.gemfile }}- + ${{ runner.os }}-gems--${{ matrix.ruby }}- + - name: Bundle Install + run: | + bundle install --jobs 4 --retry 3 + - name: Test + run: bundle exec rake spec:ruby || $ALLOW_FAILURES + + js_unit_tests: + name: JS Unit Tests + if: "contains(github.event.commits[0].message, '[ci skip]') == false" + strategy: + fail-fast: false + matrix: + os: + - ubuntu + node: + - 10 + - 12 + - 14 + runs-on: ${{ matrix.os }}-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup node + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node }} + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + - uses: actions/cache@v2 + id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ github.ref }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-yarn-${{ github.ref }}- + ${{ runner.os }}-yarn- + - name: Install JS Dependencies + run: yarn install + - name: Test + run: npm test diff --git a/.gitignore b/.gitignore index 7fee3d29..d8c97433 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ -doc -coverage pkg -spec/tmp -.DS_Store \ No newline at end of file +node_modules +Gemfile.lock +.idea/ +coverage/ +gemfiles/*.gemfile.lock +gemfiles/.bundle diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000..6a00a86a --- /dev/null +++ b/.npmignore @@ -0,0 +1,27 @@ +# https://docs.npmjs.com/misc/developers#keeping-files-out-of-your-package + +# tests +spec +coverage + +# build tools +.travis.yml + +# linters +.jscsrc +.jshintrc +.eslintrc* + +# editor settings +.idea +.editorconfig + +# Ruby code +app/assets/javascripts/i18n/ +gemfiles +lib +Gemfile* +*.gemspec +Rakefile +Appraisals + diff --git a/.rspec b/.rspec deleted file mode 100644 index b782d241..00000000 --- a/.rspec +++ /dev/null @@ -1 +0,0 @@ ---color --format documentation \ No newline at end of file diff --git a/Appraisals b/Appraisals new file mode 100644 index 00000000..5161e304 --- /dev/null +++ b/Appraisals @@ -0,0 +1,44 @@ + +appraise "i18n_0_8" do + gem "i18n", "~> 0.8.0" +end + +appraise "i18n_0_9" do + gem "i18n", "~> 0.9.0" +end + +appraise "i18n_1_0" do + gem "i18n", "~> 1.0.0" +end + +appraise "i18n_1_1" do + gem "i18n", "~> 1.1.0" +end + +appraise "i18n_1_2" do + gem "i18n", "~> 1.2.0" +end + +appraise "i18n_1_3" do + gem "i18n", "~> 1.3.0" +end + +appraise "i18n_1_4" do + gem "i18n", "~> 1.4.0" +end + +appraise "i18n_1_5" do + gem "i18n", "~> 1.5.1" +end + +appraise "i18n_1_6" do + gem "i18n", "~> 1.6.0" +end + +appraise "i18n_1_7" do + gem "i18n", "~> 1.7.0" +end + +appraise "i18n_1_8" do + gem "i18n", "~> 1.8.0" +end diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..e9297455 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,508 @@ +# Change Log +All notable changes to this project will be documented in this file. +This project adheres to [Semantic Versioning](http://semver.org/). + + +## [Unreleased] + +### Added + +- Nothing + +### Changed + +- Nothing + +### Fixed + +- Nothing + + +## [3.8.0] - 2020-10-15 + +### Added + +- [JS] Add option `scope` for `toHumanSize()` + (PR: https://github.com/fnando/i18n-js/pull/583) + + +## [3.7.1] - 2020-06-30 + +### Fixed + +- [JS] For translation missing behaviour `guess`, replace all underscores to spaces properly + (PR: https://github.com/fnando/i18n-js/pull/574) + + +## [3.7.0] - 2020-05-29 + +### Added + +- [JS] Allow options to be passed in when calling `I18n.localize`/`I18n.l` + (PR: https://github.com/fnando/i18n-js/pull/570) + + +## [3.6.0] - 2020-02-14 + +### Added + +- [Ruby] Allow `suffix` to be added to generated translations files + (PR: https://github.com/fnando/i18n-js/pull/561) + + +## [3.5.1] - 2019-12-21 + +### Changed + +- [JS] Bound shortcut functions + (PR: https://github.com/fnando/i18n-js/pull/560) + + +## [3.5.0] - 2019-11-12 + +### Added + +- [JS] Support for `%k` strftime format to match Ruby strftime + (PR: https://github.com/fnando/i18n-js/pull/554) + + +## [3.4.2] - 2019-11-11 + +### Fixed + +- [Ruby] Fix regression introduced in PR #551 + (PR: https://github.com/fnando/i18n-js/pull/555) + + +## [3.4.1] - 2019-11-01 + +### Fixed + +- [Ruby] Fix merging of plural keys to work with fallbacks that aren't overridden + (PR: https://github.com/fnando/i18n-js/pull/551) + + +## [3.4.0] - 2019-10-15 + +### Added + +- [Ruby] Allow `prefix` to be added to generated translations files + (PR: https://github.com/fnando/i18n-js/pull/549) + + +## [3.3.0] - 2019-06-06 + +### Added + +- [JS] Support for `%P`, `%Z`, and `%l` strftime formats to match Ruby strftime + (PR: https://github.com/fnando/i18n-js/pull/537) + + +## [3.2.3] - 2019-05-24 + +### Changed + +- [Ruby] Allow rails 6 to be used with this gem + (PR: https://github.com/fnando/i18n-js/pull/536) + + +## [3.2.2] - 2019-05-09 + +### Fixed + +- [JS] Return invalid date/time input values (null & undefined) as-is + (Commit: https://github.com/fnando/i18n-js/commit/869d1689ed788ff50121de492db354652971c23d) + + +## [3.2.1] - 2019-01-22 + +### Changed + +- [Ruby] `json_only` option should allow multiple locales. + (PR: https://github.com/fnando/i18n-js/pull/531) +- [Ruby] Simplified and cleaned code related to JS/JSON formatting. + (PR: https://github.com/fnando/i18n-js/pull/531) +- [JS] Use strict value comparison + +### Fixed + +- [Ruby] Relax `i18n` version requirement back to `>= 0.6.6` + (PR: https://github.com/fnando/i18n-js/pull/530) +- [Ruby] Fix merging of plural keys across locales. + (PR: https://github.com/fnando/i18n-js/pull/472) + + +## [3.2.0] - 2018-11-16 + +### Added + +- [Ruby] Add option `json_only` to generate translations in JSON + (PR: https://github.com/fnando/i18n-js/pull/524) + +### Changed + +- [Ruby] Requires `i18n` to be `>= 0.8.0` for CVE-2014-10077 + + +## [3.1.0] - 2018-11-01 + +### Added + +- [Ruby] Add option to allow setting a different I18n backend + (PR: https://github.com/fnando/i18n-js/pull/519) + +### Fixed + +- [JS] Fix missing translation when pluralizing with default scopes + (PR: https://github.com/fnando/i18n-js/pull/516) + + +## [3.0.11] - 2018-07-06 + +### Fixed + +- [JS] Fix interpolation for array with non string/null elements + (PR: https://github.com/fnando/i18n-js/pull/505) + + +## [3.0.10] - 2018-06-21 + +### Fixed + +- [JS] Fix extend method changing keys with `null` to empty objects + (PR: https://github.com/fnando/i18n-js/pull/503) +- [JS] Fix variable name in an internal method + (PR: https://github.com/fnando/i18n-js/pull/501) + + +## [3.0.9] - 2018-06-21 + +### Fixed + +- [JS] Fix translation array interpolation for array with null + + +## [3.0.8] - 2018-06-06 + +### Changed + +- [JS] Interpolate translation array too + (PR: https://github.com/fnando/i18n-js/pull/498) + + +## [3.0.7] - 2018-05-30 + +### Fixed + +- [Ruby] Fix new bug occuring when config file is absent + + +## [3.0.6] - 2018-05-30 + +### Fixed + +- [Ruby] Make JS `i18n/filtered` depends on i18n-js config too + (PR: https://github.com/fnando/i18n-js/pull/497) + + +## [3.0.5] - 2018-02-26 + +### Changed + +- [Ruby] Support `I18n` `1.0.x` + (PR: https://github.com/fnando/i18n-js/pull/492) + + +## [3.0.4] - 2018-01-26 + +### Fixed + +- [Ruby] Fix `JS::Dependencies.using_asset_pipeline?` returning true when sprockets installed but disabled + (PR: https://github.com/fnando/i18n-js/pull/488) + + +## [3.0.3] - 2018-01-02 + +### Fixed + +- [Ruby] Fix extend method when translations has array values + (PR: https://github.com/fnando/i18n-js/pull/487) + + +## [3.0.2] - 2017-10-26 + +### Changed + +- [Ruby] Avoid writing new file if a file with same content already exists + (PR: https://github.com/fnando/i18n-js/pull/473) +- [JS] Fix fallback when "3-part" locale like `zh-Hant-TW` is used + It was falling back to `zh` first instead of `zh-Hant` (see new test case added) + (PR: https://github.com/fnando/i18n-js/pull/475) + + +## [3.0.1] - 2017-08-02 + +### Changed + +- [Ruby] Relax Rails detection code to work with alternative installation methods + (PR: https://github.com/fnando/i18n-js/pull/467) +- [JS] Fix fallback when "3-part" locale like `zh-Hant-TW` is used + It fallbacks to `zh` only before, now it fallbacks to `zh-Hant` + (PR: https://github.com/fnando/i18n-js/pull/465) + + +## [3.0.0] - 2017-04-01 + +This is a fake official release, the *real* one will be `3.0.0.rc17` +And today is not April Fools' Day + +### Fixed + +- Ends the longest Release Candidate period among all ruby gems + (v3.0.0.rc1 released at 2012-05-10) + + +## [3.0.0.rc16] - 2017-03-13 + +### Changed + +- [Ruby] Drop support for Ruby < `2.1.0` + +### Fixed + +- [JS] Make defaultValue works on plural translation +- [JS] Fix UMD pattern so the global/root won’t be undefined + + +## [3.0.0.rc15] - 2016-12-07 + +### Added + +- Nothing + +### Changed + +- [JS] Allow `defaultValue` to work in pluralization + (PR: https://github.com/fnando/i18n-js/pull/433) +- [Ruby] Stop validating the fallback locales against `I18n.available_locales` + This allows some locales to be used as fallback locales, but not to be generated in JS. + (PR: https://github.com/fnando/i18n-js/pull/425) +- [Ruby] Remove dependency on gem `activesupport` + +### Fixed + +- [JS] Stop converting numeric & boolean values into objects + when merging objects with `I18n.extend` + (PR: https://github.com/fnando/i18n-js/pull/420) +- [JS] Fix I18n pluralization fallback when tree is empty + (PR: https://github.com/fnando/i18n-js/pull/435) +- [Ruby] Use old syntax to define lambda for compatibility with older Rubies + (Issue: https://github.com/fnando/i18n-js/issues/419) +- [Ruby] Fix error raised in middleware cache cleaning in parallel test + (Issue: https://github.com/fnando/i18n-js/issues/436) + + +## [3.0.0.rc14] - 2016-08-29 + +### Changed + +- [JS] Method `I18n.extend()` behave as deep merging instead of shallow merging. (https://github.com/fnando/i18n-js/pull/416) +- [Ruby] Use object/class instead of block when registering Sprockets preprocessor (https://github.com/fnando/i18n-js/pull/418) + To ensure that your cache will expire properly based on locale file content after upgrading, + you should run `rake assets:clobber` and/or other rake tasks that clear the asset cache once gem updated +- [Ruby] Detect & support rails 5 (https://github.com/fnando/i18n-js/pull/413) + + +## [3.0.0.rc13] - 2016-06-29 + +### Added + +- [Ruby] Added option `js_extend` to not generate JS code for translations with usage of `I18n.extend` ([#397](https://github.com/fnando/i18n-js/pull/397)) + +### Changed + +- Nothing + +### Fixed + +- [JS] Initialize option `missingBehaviour` & `missingTranslationPrefix` with default values ([#398](https://github.com/fnando/i18n-js/pull/398)) +- [JS] Throw an error when `I18n.strftime()` takes an invalid date ([#383](https://github.com/fnando/i18n-js/pull/383)) +- [JS] Fix default error message when translation missing to consider locale passed in options +- [Ruby] Reset middleware cache on rails startup +([#402](https://github.com/fnando/i18n-js/pull/402)) + + +## [3.0.0.rc12] - 2015-12-30 + +### Added + +- [JS] Allow extending of translation files ([#354](https://github.com/fnando/i18n-js/pull/354)) +- [JS] Allow missingPlaceholder to receive extra data for debugging ([#380](https://github.com/fnando/i18n-js/pull/380)) + +### Changed + +- Nothing + +### Fixed + +- [Ruby] Fix of missing initializer at sprockets. ([#371](https://github.com/fnando/i18n-js/pull/371)) +- [Ruby] Use proper method to register preprocessor documented by sprockets-rails. ([#376](https://github.com/fnando/i18n-js/pull/376)) +- [JS] Correctly round unprecise floating point numbers. +- [JS] Ensure objects are recognized when passed in from an iframe. ([#375](https://github.com/fnando/i18n-js/pull/375)) + + +## 3.0.0.rc11 + +### breaking changes + +### enhancements + +### bug fixes + +- [Ruby] Handle fallback locale without any translation properly ([#338](https://github.com/fnando/i18n-js/pull/338)) +- [Ruby] Prevent translation entry with null value to override value in fallback locale(s), if enabled ([#334](https://github.com/fnando/i18n-js/pull/334)) + + +## 3.0.0.rc10 + +### breaking changes + +- [Ruby] In `config/i18n-js.yml`, if you are using `%{locale}` in your filename and are referencing specific translations keys, please add `*.` to the beginning of those keys. ([#320](https://github.com/fnando/i18n-js/pull/320)) +- [Ruby] The `:except` option to exclude certain phrases now (only) accepts the same patterns the `:only` option accepts + +### enhancements + +- [Ruby] Make handling of per-locale and not-per-locale exporting to be more consistent ([#320](https://github.com/fnando/i18n-js/pull/320)) +- [Ruby] Add option `sort_translation_keys` to sort translation keys alphabetically ([#318](https://github.com/fnando/i18n-js/pull/318)) + +### bug fixes + +- [Ruby] Fix fallback logic to work with not-per-locale files ([#320](https://github.com/fnando/i18n-js/pull/320)) + + +## 3.0.0.rc9 + +### enhancements + +- [JS] Force currency number sign to be at first place using `sign_first` option, default to `true` +- [Ruby] Add option `namespace` & `pretty_print` ([#300](https://github.com/fnando/i18n-js/pull/300)) +- [Ruby] Add option `export_i18n_js` ([#301](https://github.com/fnando/i18n-js/pull/301)) +- [Ruby] Now the gem also detects pre-release versions of `rails` +- [Ruby] Add `:except` option to exclude certain phrases or groups of phrases from the + outputted translations ([#312](https://github.com/fnando/i18n-js/pull/312)) +- [JS] You can now set `I18n.missingBehavior='guess'` to have the scope string output as text instead of of the + "[missing `scope`]" message when no translation is available. + Combined that with `I18n.missingTranslationPrefix='SOMETHING'` and you can + still identify those missing strings. + ([#304](https://github.com/fnando/i18n-js/pull/304)) + +### bug fixes + +- [JS] Fix missing translation message when scope is passed in options +- [Ruby] Fix save cache directory verification when path is a symbolic link ([#329](https://github.com/fnando/i18n-js/pull/329)) + + +## 3.0.0.rc8 + +### enhancements + +- Add support for loading via AMD and CommonJS module loaders ([#266](https://github.com/fnando/i18n-js/pull/266)) +- Add `I18n.nullPlaceholder` + Defaults to I18n.missingPlaceholder (`[missing {{name}} value]`) + Set to `function() {return "";}` to match Ruby `I18n.t("name: %{name}", name: nil)` +- For date formatting, you can now also add placeholders to the date format, see README for detail +- Add fallbacks option to `i18n-js.yml`, defaults to `true` + +### bug fixes + +- Fix factory initialization so that the Node/CommonJS branch only gets executed if the environment is Node/CommonJS + (it currently will execute if module is defined in the global scope, which occurs with QUnit, for example) +- Fix pluralization rules selection for negative `count` (e.g. `-1` was lead to use `one` for pluralization) ([#268](https://github.com/fnando/i18n-js/pull/268)) +- Remove check for `Rails.configuration.assets.compile` before telling Sprockets the dependency of translations JS file + This might be the reason of many "cache not expired" issues + Discovered/reported in #277 + +## 3.0.0.rc7 + +### enhancements + +- The Rails Engine initializer is now named as `i18n-js.register_preprocessor` (https://github.com/fnando/i18n-js/pull/261) +- Rename `I18n::JS.config_file` to `I18n::JS.config_file_path` and make it configurable + Expected a `String`, default is still `config/i18n-js.yml` +- When running `rake i18n:js:export`, the `i18n.js` will also be exported to `I18n::JS.export_i18n_js_dir_path` by default +- Add `I18n::JS.export_i18n_js_dir_path` + Expected a `String`, default is `public/javascripts` + Set to `nil` will disable exporting `i18n.js` + +### bug fixes + +- Prevent toString() call on `undefined` when there is a missing interpolation value +- Added support for Rails instances without Sprockets object (https://github.com/fnando/i18n-js/pull/241) +- Fix `DEFAULT_OPTIONS` in `i18n.js` which contained an excessive comma +- Fix `nil` values are exported into JS files which causes strange translation error +- Fix pattern to replace all escaped $ in I18n.translate +- Fix JS `I18n.lookup` modifies existing locales accidentally + +## 3.0.0.rc6 + +### enhancements + +- You can now assign `I18n.locale` & `I18n.default_locale` before loading `i18n.js` in `application.html.*` + (merged to `i18n-js-pika` already) +- You can include ERB in `config/i18n-js.yml`(https://github.com/fnando/i18n-js/pull/224) +- Add support for +00:00 style time zone designator (https://github.com/fnando/i18n-js/pull/167) +- Add back rake task for export (`rake i18n:js:export`) +- Not overriding translation when manually run `I18n::JS.export` (https://github.com/fnando/i18n-js/pull/171) +- Move missing placeholder text generation into its own function (for easier debugging) (https://github.com/fnando/i18n-js/pull/169) +- Add support for milliseconds (`lll` in `yyyy-mm-ddThh:mm:ss.lllZ`) (https://github.com/fnando/i18n-js/pull/192) +- Add back i18n-js.yml config file generator : `rails generate i18n:js:config` (https://github.com/fnando/i18n-js/pull/225) + +### bug fixes + +- `I18n::JS.export` no longer exports locales other than those in `I18n.available_locales`, if `I18n.available_locales` is set +- I18.t supports the base scope through the options argument +- I18.t accepts an array as the scope +- Fix regression: asset not being reloaded in development when translation changed +- Requires `i18n` to be `~> 0.6`, `0.5` does not work at all +- Fix using multi-star scope with top-level translation key (https://github.com/fnando/i18n-js/pull/221) + + +## Before 3.0.0.rc5 + +- Things happened. + + + +[Unreleased]: https://github.com/fnando/i18n-js/compare/v3.8.0...HEAD +[3.8.0]: https://github.com/fnando/i18n-js/compare/v3.7.1...v3.8.0 +[3.7.1]: https://github.com/fnando/i18n-js/compare/v3.7.0...v3.7.1 +[3.7.0]: https://github.com/fnando/i18n-js/compare/v3.6.0...v3.7.0 +[3.6.0]: https://github.com/fnando/i18n-js/compare/v3.5.1...v3.6.0 +[3.5.1]: https://github.com/fnando/i18n-js/compare/v3.5.0...v3.5.1 +[3.5.0]: https://github.com/fnando/i18n-js/compare/v3.4.2...v3.5.0 +[3.4.2]: https://github.com/fnando/i18n-js/compare/v3.4.1...v3.4.2 +[3.4.1]: https://github.com/fnando/i18n-js/compare/v3.4.0...v3.4.1 +[3.4.0]: https://github.com/fnando/i18n-js/compare/v3.3.0...v3.4.0 +[3.3.0]: https://github.com/fnando/i18n-js/compare/v3.2.3...v3.3.0 +[3.2.3]: https://github.com/fnando/i18n-js/compare/v3.2.2...v3.2.3 +[3.2.2]: https://github.com/fnando/i18n-js/compare/v3.2.1...v3.2.2 +[3.2.1]: https://github.com/fnando/i18n-js/compare/v3.2.0...v3.2.1 +[3.2.0]: https://github.com/fnando/i18n-js/compare/v3.1.0...v3.2.0 +[3.1.0]: https://github.com/fnando/i18n-js/compare/v3.0.11...v3.1.0 +[3.0.11]: https://github.com/fnando/i18n-js/compare/v3.0.10...v3.0.11 +[3.0.10]: https://github.com/fnando/i18n-js/compare/v3.0.9...v3.0.10 +[3.0.9]: https://github.com/fnando/i18n-js/compare/v3.0.8...v3.0.9 +[3.0.8]: https://github.com/fnando/i18n-js/compare/v3.0.7...v3.0.8 +[3.0.7]: https://github.com/fnando/i18n-js/compare/v3.0.6...v3.0.7 +[3.0.6]: https://github.com/fnando/i18n-js/compare/v3.0.5...v3.0.6 +[3.0.5]: https://github.com/fnando/i18n-js/compare/v3.0.4...v3.0.5 +[3.0.4]: https://github.com/fnando/i18n-js/compare/v3.0.3...v3.0.4 +[3.0.3]: https://github.com/fnando/i18n-js/compare/v3.0.2...v3.0.3 +[3.0.2]: https://github.com/fnando/i18n-js/compare/v3.0.1...v3.0.2 +[3.0.1]: https://github.com/fnando/i18n-js/compare/v3.0.0...v3.0.1 +[3.0.0]: https://github.com/fnando/i18n-js/compare/v3.0.0.rc16...v3.0.0 +[3.0.0.rc16]: https://github.com/fnando/i18n-js/compare/v3.0.0.rc15...v3.0.0.rc16 +[3.0.0.rc15]: https://github.com/fnando/i18n-js/compare/v3.0.0.rc14...v3.0.0.rc15 +[3.0.0.rc14]: https://github.com/fnando/i18n-js/compare/v3.0.0.rc13...v3.0.0.rc14 +[3.0.0.rc13]: https://github.com/fnando/i18n-js/compare/v3.0.0.rc12...v3.0.0.rc13 +[3.0.0.rc12]: https://github.com/fnando/i18n-js/compare/v3.0.0.rc11...v3.0.0.rc12 diff --git a/Gemfile b/Gemfile index e45e65f8..3be9c3cd 100644 --- a/Gemfile +++ b/Gemfile @@ -1,2 +1,2 @@ -source :rubygems +source "https://rubygems.org" gemspec diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 3e73dd39..00000000 --- a/Gemfile.lock +++ /dev/null @@ -1,51 +0,0 @@ -PATH - remote: . - specs: - i18n-js (2.1.2) - i18n - -GEM - remote: http://rubygems.org/ - specs: - activesupport (3.1.1) - multi_json (~> 1.0) - coderay (0.9.8) - diff-lcs (1.1.3) - fakeweb (1.3.0) - i18n (0.6.0) - method_source (0.6.7) - ruby_parser (>= 2.3.1) - multi_json (1.0.3) - notifier (0.1.4) - pry (0.9.7.4) - coderay (~> 0.9.8) - method_source (~> 0.6.7) - ruby_parser (>= 2.3.1) - slop (~> 2.1.0) - rake (0.9.2.2) - rspec (2.7.0) - rspec-core (~> 2.7.0) - rspec-expectations (~> 2.7.0) - rspec-mocks (~> 2.7.0) - rspec-core (2.7.1) - rspec-expectations (2.7.0) - diff-lcs (~> 1.1.2) - rspec-mocks (2.7.0) - ruby_parser (2.3.1) - sexp_processor (~> 3.0) - sexp_processor (3.0.8) - slop (2.1.0) - spec-js (0.1.0.beta.3) - notifier - -PLATFORMS - ruby - -DEPENDENCIES - activesupport (>= 3.0.0) - fakeweb - i18n-js! - pry - rake - rspec (~> 2.6) - spec-js (~> 0.1.0.beta.0) diff --git a/README.md b/README.md new file mode 100644 index 00000000..e5709326 --- /dev/null +++ b/README.md @@ -0,0 +1,1068 @@ +

+ i18n.js +

+ +

+ It's a small library to provide the Rails I18n translations on the JavaScript. +

+ +

+ Tests + Gem Version + npm + License: MIT + Build Status + Coverage Status + Gitter +

+ +--- + +Features: + +- Pluralization +- Date/Time localization +- Number localization +- Locale fallback +- Asset pipeline support +- Lots more! :) + +## Version Notice + +The `master` branch (including this README) is for latest `3.0.0` instead of +`2.x`. + +## Usage + +### Installation + +#### Rails app + +Add the gem to your Gemfile. + +```ruby +gem "i18n-js" +``` + +#### Rails with [webpacker](https://github.com/rails/webpacker) + +If you're using `webpacker`, you may need to add the dependencies to your client +with: + +``` +yarn add i18n-js +# or, if you're using npm, +npm install i18n-js +``` + +For more details, see +[this gist](https://gist.github.com/bazzel/ecdff4718962e57c2d5569cf01d332fe). + +#### Rails app with [Asset Pipeline](http://guides.rubyonrails.org/asset_pipeline.html) + +If you're using the +[asset pipeline](http://guides.rubyonrails.org/asset_pipeline.html), then you +must add the following line to your `app/assets/javascripts/application.js`. + +```javascript +// +// This is optional (in case you have `I18n is not defined` error) +// If you want to put this line, you must put it BEFORE `i18n/translations` +//= require i18n +// Some people even need to add the extension to make it work, see https://github.com/fnando/i18n-js/issues/283 +//= require i18n.js +// +// This is a must +//= require i18n/translations +``` + +#### Rails app without [Asset Pipeline](http://guides.rubyonrails.org/asset_pipeline.html) + +First, put this in your `application.html` (layout file). Then get the JS files +following the instructions below. + +```erb +<%# This is just an example, you can put `i18n.js` and `translations.js` anywhere you like %> +<%# Unlike the Asset Pipeline example, you need to require both **in order** %> +<%= javascript_include_tag "i18n" %> +<%= javascript_include_tag "translations", skip_pipeline: true %> +``` + +**There are two ways to get `translations.js` (For Rails app without Asset +Pipeline).** + +1. This `translations.js` file can be automatically generated by the + `I18n::JS::Middleware`. Just add `config.middleware.use I18n::JS::Middleware` + to your `config/application.rb` file. +2. If you can't or prefer not to generate this file, you can move the middleware + line to your `config/environments/development.rb` file and run + `rake i18n:js:export` before deploying. This will export all translation + files, including the custom scopes you may have defined on + `config/i18n-js.yml`. If `I18n.available_locales` is set (e.g. in your Rails + `config/application.rb` file) then only the specified locales will be + exported. Current version of `i18n.js` will also be exported to avoid version + mismatching by downloading. + +#### Export Configuration (For translations) + +Exported translation files generated by `I18n::JS::Middleware` or +`rake i18n:js:export` can be customized with config file `config/i18n-js.yml` +(use `rails generate i18n:js:config` to create it). You can even get more files +generated to different folders and with different translations to best suit your +needs. The config file also affects developers using Asset Pipeline to require +translations. Except the option `file`, since all translations are required by +adding `//= require i18n/translations`. + +Examples: + +```yaml +translations: + - file: "public/javascripts/path-to-your-messages-file.js" + only: "*.date.formats" + - file: "public/javascripts/path-to-your-second-file.js" + only: ["*.activerecord", "*.admin.*.title"] +``` + +If `only` is omitted all the translations will be saved. Also, make sure you add +that initial `*`; it specifies that all languages will be exported. If you want +to export only one language, you can do something like this: + +```yaml +translations: + - file: "public/javascripts/en.js" + only: "en.*" + - file: "public/javascripts/pt-BR.js" + only: "pt-BR.*" +``` + +Optionally, you can auto generate a translation file per available locale if you +specify the `%{locale}` placeholder. + +```yaml +translations: + - file: "public/javascripts/i18n/%{locale}.js" + only: "*" + - file: "public/javascripts/frontend/i18n/%{locale}.js" + only: ["*.frontend", "*.users.*"] +``` + +You can also include ERB in your config file. + +```yaml +translations: +<% Widgets.each do |widget| %> +- file: <%= "'#{widget.file}'" %> + only: <%= "'#{widget.only}'" %> +<% end %> +``` + +You are able to exclude certain phrases or whole groups of phrases by specifying +the YAML key(s) in the `except` configuration option. The outputted JS +translations file (exported or generated by the middleware) will omit any keys +listed in `except` configuration param: + +```yaml +translations: + - except: ["*.active_admin", "*.ransack", "*.activerecord.errors"] +``` + +#### Export Configuration (For other things) + +- `I18n::JS.config_file_path` Expected Type: `String` Default: + `config/i18n-js.yml` Behaviour: Try to read the config file from that location + +- `I18n::JS.export_i18n_js_dir_path` Expected Type: `String` Default: + `public/javascripts` Behaviour: + + - Any `String`: considered as a relative path for a folder to `Rails.root` and + export `i18n.js` to that folder for `rake i18n:js:export` + - Any non-`String` (`nil`, `false`, `:none`, etc): Disable `i18n.js` exporting + +- `I18n::JS.sort_translation_keys` Expected Type: `Boolean` Default: `true` + Behaviour: + + - Sets whether or not to deep sort all translation keys in order to generate + identical output for the same translations + - Set to true to ensure identical asset fingerprints for the asset pipeline + +- You may also set `export_i18n_js` and `sort_translation_keys` in your config + file, e.g.: + +```yaml +export_i18n_js: false +# OR +export_i18n_js: "my/path" + +sort_translation_keys: false + +translations: + - ... +``` + +To find more examples on how to use the configuration file please refer to the +tests. + +#### Fallbacks + +If you specify the `fallbacks` option, you will be able to fill missing +translations with those inside fallback locale(s). Default value is `true`. + +Examples: + +```yaml +fallbacks: true + +translations: + - file: "public/javascripts/i18n/%{locale}.js" + only: "*" +``` + +This will enable merging fallbacks into each file. (set to `false` to disable). +If you use `I18n` with fallbacks, the fallbacks defined there will be used. +Otherwise `I18n.default_locale` will be used. + +```yaml +fallbacks: :de + +translations: + - file: "public/javascripts/i18n/%{locale}.js" + only: "*" +``` + +Here, the specified locale `:de` will be used as fallback for all locales. + +```yaml +fallbacks: + fr: ["de", "en"] + de: "en" + +translations: + - file: "public/javascripts/i18n/%{locale}.js" + only: "*" +``` + +Fallbacks defined will be used, if not defined (e.g. `:pl`) `I18n.fallbacks` or +`I18n.default_locale` will be used. + +```yaml +fallbacks: :default_locale + +translations: + - file: "public/javascripts/i18n/%{locale}.js" + only: "*" +``` + +Setting the option to `:default_locale` will enforce the fallback to use the +`I18n.default_locale`, ignoring `I18n.fallbacks`. + +Examples: + +```yaml +fallbacks: false + +translations: + - file: "public/javascripts/i18n/%{locale}.js" + only: "*" +``` + +You must disable this feature by setting the option to `false`. + +To find more examples on how to use the configuration file please refer to the +tests. + +#### Namespace + +Setting the `namespace` option will change the namespace of the output +Javascript file to something other than `I18n`. This can be useful in +no-conflict scenarios. Example: + +```yaml +translations: + - file: "public/javascripts/i18n/translations.js" + namespace: "MyNamespace" +``` + +will create: + +``` +MyNamespace.translations || (MyNamespace.translations = {}); +MyNamespace.translations["en"] = { ... } +``` + +### Adding prefix & suffix to the translations file(s) + +Setting the `prefix: "import I18n from 'i18n-js';\n"` option will add the line +at the beginning of the resultant translation file. This can be useful to use +this gem with the [i18n-js](https://www.npmjs.com/package/i18n-js) npm package, +which is quite useful to use it with webpack. The user should provide the +semi-colon and the newline character if needed. + +For example: + +```yaml +translations: + - file: "public/javascripts/i18n/translations.js" + prefix: "import I18n from 'i18n-js';\n" +``` + +will create: + +``` +import I18n from 'i18n-js'; +I18n.translations || (I18n.translations = {}); +``` + +`suffix` option is added in https://github.com/fnando/i18n-js/pull/561. +It's similar to `prefix` so won't explain it in details. + +#### Pretty Print + +Set the `pretty_print` option if you would like whitespace and indentation in +your output file (default: false) + +```yaml +translations: + - file: "public/javascripts/i18n/translations.js" + pretty_print: true +``` + +#### Javascript Deep Merge (:js_extend option) + +By default, the output file Javascript will call the `I18n.extend` method to +ensure that newly loaded locale files are deep-merged with any locale data +already in memory. To disable this either globally or per-file, set the +`js_extend` option to false + +```yaml +js_extend: false # this will disable Javascript I18n.extend globally +translations: + - file: "public/javascripts/i18n/translations.js" + js_extend: false # this will disable Javascript I18n.extend for this file +``` + +#### Vanilla JavaScript + +Just add the `i18n.js` file to your page. You'll have to build the translations +object by hand or using your favorite programming language. More info below. + +#### Via NPM with webpack and CommonJS + +Add the following line to your package.json dependencies where version is the +version you want: + +```javascript +"i18n-js": "{version_constraint}" + +// Or if you want unreleased version +// npm install requires it to be the gzipped tarball, see [npm install](https://www.npmjs.org/doc/cli/npm-install.html) +"i18n-js": "https://github.com/fnando/i18n-js/archive/{tag_name_or_branch_name_or_commit_sha}.tar.gz" +``` + +Run npm install then use via + +```javascript +var i18n = require("i18n-js"); +``` + +### Setting up + +You **don't** need to set up a thing. The default settings will work just okay. +But if you want to split translations into several files or specify contexts, +you can follow the rest of this setting up section. + +Set your locale is easy as + +```javascript +I18n.defaultLocale = "pt-BR"; +I18n.locale = "pt-BR"; +I18n.currentLocale(); +// pt-BR +``` + +**NOTE:** You can now apply your configuration **before I18n** is loaded like +this: + +```javascript +I18n = {}; // You must define this object in top namespace, which should be `window` +I18n.defaultLocale = "pt-BR"; +I18n.locale = "pt-BR"; + +// Load I18n from `i18n.js`, `application.js` or whatever + +I18n.currentLocale(); +// pt-BR +``` + +In practice, you'll have something like the following in your +`application.html.erb`: + +```erb + +``` + +You can use translate your messages: + +```javascript +I18n.t("some.scoped.translation"); +// or translate with explicit setting of locale +I18n.t("some.scoped.translation", { locale: "fr" }); +``` + +You can also interpolate values: + +```javascript +// You need the `translations` object setup first +I18n.translations["en"] = { + greeting: "Hello %{name}", +}; + +I18n.t("greeting", { name: "John Doe" }); +``` + +You can set default values for missing scopes: + +```javascript +// simple translation +I18n.t("some.missing.scope", { defaultValue: "A default message" }); + +// with interpolation +I18n.t("noun", { defaultValue: "I'm a {{noun}}", noun: "Mac" }); +``` + +You can also provide a list of default fallbacks for missing scopes: + +```javascript +// As a scope +I18n.t("some.missing.scope", { defaults: [{ scope: "some.existing.scope" }] }); + +// As a simple translation +I18n.t("some.missing.scope", { defaults: [{ message: "Some message" }] }); +``` + +Default values must be provided as an array of hashes where the key is the type +of translation desired, a `scope` or a `message`. The translation returned will +be either the first scope recognized, or the first message defined. + +The translation will fallback to the `defaultValue` translation if no scope in +`defaults` matches and if no default of type `message` is found. + +Translation fallback can be enabled by enabling the `I18n.fallbacks` option: + +```erb + +``` + +By default missing translations will first be looked for in less specific +versions of the requested locale and if that fails by taking them from your +`I18n.defaultLocale`. + +```javascript +// if I18n.defaultLocale = "en" and translation doesn't exist +// for I18n.locale = "de-DE" this key will be taken from "de" locale scope +// or, if that also doesn't exist, from "en" locale scope +I18n.t("some.missing.scope"); +``` + +Custom fallback rules can also be specified for a particular language. There are +three different ways of doing it so: + +```javascript +I18n.locales.no = ["nb", "en"]; +I18n.locales.no = "nb"; +I18n.locales.no = function (locale) { + return ["nb"]; +}; +``` + +By default a missing translation will be displayed as + + [missing "name of scope" translation] + +While you are developing or if you do not want to provide a translation in the +default language you can set + +```javascript +I18n.missingBehaviour = "guess"; +``` + +this will take the last section of your scope and guess the intended value. +Camel case becomes lower cased text and underscores are replaced with space + + questionnaire.whatIsYourFavorite_ChristmasPresent + +becomes "what is your favorite Christmas present" + +In order to still detect untranslated strings, you can set +`i18n.missingTranslationPrefix` to something like: + +```javascript +I18n.missingTranslationPrefix = "EE: "; +``` + +And result will be: + +```javascript +"EE: what is your favorite Christmas present"; + +``` + +This will help you doing automated tests against your localisation assets. + +Some people prefer returning `null` for missing translation: + +```javascript +I18n.missingTranslation = function () { + return undefined; +}; +``` + +Pluralization is possible as well and by default provides English rules: + +```javascript +I18n.t("inbox.counting", { count: 10 }); // You have 10 messages +``` + +The sample above expects the following translation: + +```yaml +en: + inbox: + counting: + one: You have 1 new message + other: You have {{count}} new messages + zero: You have no messages +``` + +**NOTE:** Rails I18n recognizes the `zero` option. + +If you need special rules just define them for your language, for example +Russian, just add a new pluralizer: + +```javascript +I18n.pluralization["ru"] = function (count) { + var key = + count % 10 == 1 && count % 100 != 11 + ? "one" + : [2, 3, 4].indexOf(count % 10) >= 0 && + [12, 13, 14].indexOf(count % 100) < 0 + ? "few" + : count % 10 == 0 || + [5, 6, 7, 8, 9].indexOf(count % 10) >= 0 || + [11, 12, 13, 14].indexOf(count % 100) >= 0 + ? "many" + : "other"; + return [key]; +}; +``` + +You can find all rules on +. + +If you're using the same scope over and over again, you may use the `scope` +option. + +```javascript +var options = { scope: "activerecord.attributes.user" }; + +I18n.t("name", options); +I18n.t("email", options); +I18n.t("username", options); +``` + +You can also provide an array as scope. + +```javascript +// use the greetings.hello scope +I18n.t(["greetings", "hello"]); +``` + +#### Number formatting + +Similar to Rails helpers, you have localized number and currency formatting. + +```javascript +I18n.l("currency", 1990.99); +// $1,990.99 + +I18n.l("number", 1990.99); +// 1,990.99 + +I18n.l("percentage", 123.45); +// 123.450% +``` + +To have more control over number formatting, you can use the `I18n.toNumber`, +`I18n.toPercentage`, `I18n.toCurrency` and `I18n.toHumanSize` functions. + +```javascript +I18n.toNumber(1000); // 1,000.000 +I18n.toCurrency(1000); // $1,000.00 +I18n.toPercentage(100); // 100.000% +``` + +The `toNumber` and `toPercentage` functions accept the following options: + +- `precision`: defaults to `3` +- `separator`: defaults to `.` +- `delimiter`: defaults to `,` +- `strip_insignificant_zeros`: defaults to `false` + +See some number formatting examples: + +```javascript +I18n.toNumber(1000, { precision: 0 }); // 1,000 +I18n.toNumber(1000, { delimiter: ".", separator: "," }); // 1.000,000 +I18n.toNumber(1000, { delimiter: ".", precision: 0 }); // 1.000 +``` + +The `toCurrency` function accepts the following options: + +- `precision`: sets the level of precision +- `separator`: sets the separator between the units +- `delimiter`: sets the thousands delimiter +- `format`: sets the format of the output string +- `unit`: sets the denomination of the currency +- `strip_insignificant_zeros`: defaults to `false` +- `sign_first`: defaults to `true` + +You can provide only the options you want to override: + +```javascript +I18n.toCurrency(1000, { precision: 0 }); // $1,000 +``` + +The `toHumanSize` function accepts the following options: + +- `precision`: defaults to `1` +- `separator`: defaults to `.` +- `delimiter`: defaults to `""` +- `strip_insignificant_zeros`: defaults to `false` +- `format`: defaults to `%n%u` +- `scope`: defaults to `""` + + + +```javascript +I18n.toHumanSize(1234); // 1KB +I18n.toHumanSize(1234 * 1024); // 1MB +``` + +#### Date formatting + +```javascript +// accepted formats +I18n.l("date.formats.short", "2009-09-18"); // yyyy-mm-dd +I18n.l("time.formats.short", "2009-09-18 23:12:43"); // yyyy-mm-dd hh:mm:ss +I18n.l("time.formats.short", "2009-11-09T18:10:34"); // JSON format with local Timezone (part of ISO-8601) +I18n.l("time.formats.short", "2009-11-09T18:10:34Z"); // JSON format in UTC (part of ISO-8601) +I18n.l("date.formats.short", 1251862029000); // Epoch time +I18n.l("date.formats.short", "09/18/2009"); // mm/dd/yyyy +I18n.l("date.formats.short", new Date()); // Date object +``` + +You can also add placeholders to the date format: + +```javascript +I18n.translations["en"] = { + date: { + formats: { + ordinal_day: "%B %{day}", + }, + }, +}; + +I18n.l("date.formats.ordinal_day", "2009-09-18", { day: "18th" }); // Sep 18th +``` + +If you prefer, you can use the `I18n.toTime` and `I18n.strftime` functions to +format dates. + +```javascript +var date = new Date(); +I18n.toTime("date.formats.short", "2009-09-18"); +I18n.toTime("date.formats.short", date); +I18n.strftime(date, "%d/%m/%Y"); +``` + +The accepted formats for `I18n.strftime` are: + + %a - The abbreviated weekday name (Sun) + %A - The full weekday name (Sunday) + %b - The abbreviated month name (Jan) + %B - The full month name (January) + %c - The preferred local date and time representation + %d - Day of the month (01..31) + %-d - Day of the month (1..31) + %H - Hour of the day, 24-hour clock (00..23) + %-H/%k - Hour of the day, 24-hour clock (0..23) + %I - Hour of the day, 12-hour clock (01..12) + %-I/%l - Hour of the day, 12-hour clock (1..12) + %m - Month of the year (01..12) + %-m - Month of the year (1..12) + %M - Minute of the hour (00..59) + %-M - Minute of the hour (0..59) + %p - Meridian indicator (AM or PM) + %P - Meridian indicator (am or pm) + %S - Second of the minute (00..60) + %-S - Second of the minute (0..60) + %w - Day of the week (Sunday is 0, 0..6) + %y - Year without a century (00..99) + %-y - Year without a century (0..99) + %Y - Year with century + %z/%Z - Timezone offset (+0545) + +Check out `spec/*.spec.js` files for more examples! + +#### Using pluralization and number formatting together + +Sometimes you might want to display translation with formatted number, like +adding thousand delimiters to displayed number You can do this: + +```json +{ + "en": { + "point": { + "one": "1 Point", + "other": "{{formatted_number}} Points", + "zero": "0 Points" + } + } +} +``` + +```js +var point_in_number = 1000; +I18n.t("point", { + count: point_in_number, + formatted_number: I18n.toNumber(point_in_number), +}); +``` + +Output should be `1,000 points` + +## Using multiple exported translation files on a page. + +This method is useful for very large apps where a single contained +translations.js file is not desirable. Examples would be a global translations +file and a more specific route translation file. + +### Rails without asset pipeline + +1. Setup your `config/i18n-js.yml` to have multiple files and try to minimize + any overlap. + +```yaml +sort_translation_keys: true +fallbacks: false + +translations: + + file: "app/assets/javascript/nls/welcome.js" + only: + + '*.welcome.*' + + + file: "app/assets/javascript/nls/albums.js" + only: + + '*.albums.*' + + + file: "app/assets/javascript/nls/global.js" + only: + + '*' + # Exempt any routes specific translations from being + # included in the global translation file + except: + + '*.welcome.*' + + '*.albums.*' +``` + +When `rake i18n:js:export` is executed it will create 3 translations files that +can be loaded via the `javascript_include_tag` + +2. Add the `javascript_include_tag` to your layout and to any route specific + files that will require it. + +```ruby + # views/layouts/application.html.erb + <%= javascript_include_tag( + "i18n" + "nls/global" + ) %> +``` + +and in the route specific + +```ruby + # views/welcome/index.html.erb + <%= javascript_include_tag( + "nls/welcome" + ) %> +``` + +3. Make sure that you add these files to your `config/application.rb` + +```ruby + config.assets.precompile += %w( + i18n + nls/* + ) +``` + +### Using require.js / r.js + +To use this with require.js we are only going to change a few things from above. + +1. In your `config/i18n-js.yml` we need to add a better location for the i18n to + be exported to. You want to use this location so that it can be properly + precompiled by r.js. + +```yaml +export_i18n_js: "app/assets/javascript/nls" +``` + +2. In your `config/require.yml` we need to add a map, shim all the translations, + and include them into the appropriate modules + +```yaml +# In your maps add (if you do not have this you will need to add it) +map: + '*': + i18n: 'nls/i18n' + +# In your shims +shims: + nls/welcome: + deps: + + i18n + + nls/global: + deps: + + i18n + +# Finally in your modules +modules: + + name: 'application' + include: + + i18n + + 'nls/global' + + + name: 'welcome' + exclude: + + application + include: + + 'nls/welcome' +``` + +3. When `rake assets:precompile` is executed it will optimize the translations + into the correct modules so they are loaded with their assigned module, and + loading them with requirejs is as simple as requiring any other shim. + +```javascript +define(["welcome/other_asset", "nls/welcome"], function (otherAsset) { + // ... +}); +``` + +4. (optional) As an additional configuration we can make a task to be run before + the requirejs optimizer. This will allow any automated scripts that run the + requirejs optimizer to export the strings before we run r.js. + +```rake +# lib/tasks/i18n.rake +Rake::Task[:'i18n:js:export'].prerequisites.clear +task :'i18n:js:export' => :'i18n:js:before_export' +task :'requirejs:precompile:external' => :'i18n:js:export' + +namespace :i18n do + namespace :js do + task :before_export => :'assets:environment' do + I18n.load_path += Dir[Rails.root.join('config', 'locales', '*.{yml,rb}')] + I18n.backend.load_translations + end + end +end +``` + +## Using I18n.js with other languages (Python, PHP, ...) + +The JavaScript library is language agnostic; so you can use it with PHP, Python, +[your favorite language here]. The only requirement is that you need to set the +`translations` attribute like following: + +```javascript +I18n.translations = {}; + +I18n.translations["en"] = { + message: "Some special message for you", +}; + +I18n.translations["pt-BR"] = { + message: "Uma mensagem especial para você", +}; +``` + +## Known Issues + +### Missing translations in precompiled file(s) after adding any new locale file + +Due to the design of `sprockets`: + +- `depend_on` only takes file paths, not directory paths +- registered `preprocessors` are only run when the fingerprint of any asset + file, including `.erb` files, is changed + +This means that new locale files will not be detected, and so they will not +trigger a i18n-js refresh. There are a few approaches to work around this: + +1. You can force i18n-js to update its translations by completely clearing the + assets cache. Use one of the following: + +```bash +$ rake assets:clobber +# Or, with older versions of Rails: +$ rake tmp:cache:clear +``` + +These commands will remove _all_ fingerprinted assets, and you will have to +recompile them with + +```bash +$ rake assets:precompile +``` + +or similar commands. If you are precompiling assets on the target machine(s), +cached pages may be broken by this, so they will need to be refreshed. + +2. You can change something in a different locale file. + +3. Finally, you can change `config.assets.version`. + +**Note:** See issue [#213](https://github.com/fnando/i18n-js/issues/213) for +more details and discussion of this issue. + +### Translations in JS are not updated when Sprockets not loaded before this gem + +The "rails engine" declaration will try to detect existence of "sprockets" +before adding the initailizer If sprockets is loaded after this gem, the +preprocessor for making JS translations file cache to depend on content of +locale files will not be hooked. So ensure sprockets is loaded before this gem +by moving the entry of sprockets in the Gemfile or adding "require" statements +for sprockets somewhere. + +**Note:** See issue [#404](https://github.com/fnando/i18n-js/issues/404) for +more details and discussion of this issue. + +### JS `I18n.toCurrency` & `I18n.toNumber` cannot handle large integers + +The above methods use `toFixed` and it only supports 53 bit integers. Ref: +http://2ality.com/2012/07/large-integers.html + +Feel free to find & discuss possible solution(s) at issue +[#511](https://github.com/fnando/i18n-js/issues/511) + +### Only works with `Simple` backend + +If you set `I18n.backend` to something other than the default `Simple` backend, +you will likely get an exception like this: + +``` +Undefined method 'initialized?' for +``` + +For now, i18n-js is only compatible with the `Simple` backend. If you need a +more sophisticated backend for your rails application, like +`I18n::Backend::ActiveRecord`, you can setup i18n-js to get translations from a +separate `Simple` backend, by adding the following in an initializer: + +```ruby +I18n::JS.backend = I18n.backend +I18n.backend = I18n::Backend::Chain.new(, I18n.backend) +``` + +This will use your backend with the default `Simple` backend as fallback, while +i18n-js only sees and uses the simple backend. This means however, that only +translations from your static locale files will be present in JavaScript. + +If you do cannot use a `Chain`-Backend for some reason, you can also set + +```ruby +I18n::JS.backend = I18n::Backend::Simple.new +I18n.backend = +``` + +However, the automatic reloading of translations in developement will not work +in this case. This is because Rails calls `I18n.reload!` for each request in +development, but `reload!` will not be called on `I18n::JS.backend`, since it is +a different object. One option would be to patch `I18n.reload!` in an +initializer: + +```ruby +module I18n + def self.reload! + I18n::JS.backend.reload! + super + end +end +``` + +See issue [#428](https://github.com/fnando/i18n-js/issues/428) for more details +and discussion of this issue. + +## Maintainer + +- Nando Vieira - + +## Contributing + +Once you've made your great commits: + +1. [Fork](http://help.github.com/forking/) I18n.js +2. Create a branch with a clear name +3. Make your changes (Please also add/change spec, README and CHANGELOG if + applicable) +4. Push changes to the created branch +5. [Create an Pull Request](http://github.com/fnando/i18n-js/pulls) +6. That's it! + +Please respect the indentation rules and code style. And use 2 spaces, not tabs. +And don't touch the versioning thing. + +## Running tests + +You can run I18n tests using Node.js or your browser. + +To use Node.js, install the `jasmine-node` library: + + $ npm install jasmine-node + +Then execute the following command from the lib's root directory: + + $ npm test + +To run using your browser, just open the `spec/js/specs.html` file. + +You can run both Ruby and JavaScript specs with `rake spec`. + +## License + +(The MIT License) + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the 'Software'), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.rdoc b/README.rdoc deleted file mode 100644 index fd6bfd3a..00000000 --- a/README.rdoc +++ /dev/null @@ -1,320 +0,0 @@ -= I18n for JavaScript - -It's a small library to provide the Rails I18n translations on the Javascript. - -== Usage - -=== Installation - - gem install i18n-js - -=== Setting up - -You don't need to set up a thing. The default settings will work just okay. But if you want to split translations into several files or specify specific contexts, you can follow the rest of this setting up section. - -==== Rails <= 3.0 - -Run rake i18n:js:setup to copy i18n.js to your javascript directory and i18n-js.yml to your config folder (if not already present). Then you're ready to go! - -==== Rails >= 3.1 - -Add the following lines to your application.js to make the javascripts and translations available to your app: - - //= require i18n - //= require i18n/translations - -If asset pipeline has been disabled for your Rails application, then you will need to run rake i18n:js:setup to copy i18n-js.yml to your config folder (if not already present). - -==== Exporting translations - -You can export the translations file by running rake i18n:js:export. -Translations will be automatically exported in development mode. - -==== Configuration - -Translation files can be customized. You can even get more files generated to different folders and with different translations to best suit your needs. - -Examples: - - translations: - - file: 'public/javascripts/path-to-your-messages-file.js' - only: '*.date.formats' - - file: 'public/javascripts/path-to-your-second-file.js' - only: ['*.activerecord', '*.admin.*.title'] - -If only is omitted all the translations will be saved. Also, make sure you add that initial *; it specifies that all languages will be exported. If you want to export only one language, you can do something like this: - - translations: - - file: 'public/javascripts/en.js' - only: 'en.*' - - file: 'public/javascripts/pt-BR.js' - only: 'pt-BR.*' - -Optionally, you can auto generate a translation file per available locale if you specify the %{locale} placeholder. - - translations: - - file: "public/javascripts/i18n/%{locale}.js" - only: '*' - - file: "public/javascripts/frontend/i18n/%{locale}.js" - only: ['frontend', 'users'] - -To find more examples on how to use the configuration file please refer to the tests. - -=== On the Javascript - -Set your locale is easy as - - I18n.defaultLocale = "pt-BR"; - I18n.locale = "pt-BR"; - I18n.currentLocale(); - // pt-BR - -In practice, you'll have something like the following in your application.html.erb: - - - -You can use translate your messages: - - I18n.t("some.scoped.translation"); - // or translate with explicite setting of locale - I18n.t("some.scoped.translation", {locale: "fr"}); - -You can also interpolate values: - - I18n.t("hello", {name: "John Doe"}); - -The sample above will assume that you have the following translations in your -config/locales/*.yml: - - en: - hello: "Hello {{name}}!" - -You can set default values for missing scopes: - - // simple translation - I18n.t("some.missing.scope", {defaultValue: "A default message"}); - - // with interpolation - I18n.t("noun", {defaultValue: "I'm a {{noun}}", noun: "Mac"}); - -Translation fallback can be enabled by adding to your application.html.erb: - - - -By default missing translations will first be looked for in less -specific versions of the requested locale and if that fails by taking -them from your I18n.defaultLocale. -Example: - // if I18n.defaultLocale = "en" and translation doesn't exist for I18n.locale = "de-DE" - I18n.t("some.missing.scope"); - // this key will be taken from "de" locale scope - // or, if that also doesn't exist, from "en" locale scope - -Custom fallback rules can also be specified for a particular language, -for example: - - I18n.fallbackRules.no = [ "nb", "en" ]; - -Pluralization is possible as well and by default provides english rules: - - I18n.t("inbox.counting", {count: 10}); // You have 10 messages - -The sample above expects the following translation: - - en: - inbox: - counting: - one: You have 1 new message - other: You have {{count}} new messages - zero: You have no messages - -NOTE: Rais I18n recognizes the +zero+ option. - -If you need special rules just define them for your language, for example for ru locale in application.js: - - I18n.pluralizationRules.ru = function (n) { - return n % 10 == 1 && n % 100 != 11 ? "one" : [2, 3, 4].indexOf(n % 10) >= 0 && [12, 13, 14].indexOf(n % 100) < 0 ? "few" : n % 10 == 0 || [5, 6, 7, 8, 9].indexOf(n % 10) >= 0 || [11, 12, 13, 14].indexOf(n % 100) >= 0 ? "many" : "other"; - } - -You can find all rules on http://unicode.org/repos/cldr-tmp/trunk/diff/supplemental/language_plural_rules.html - -If you're using the same scope over and over again, you may use the +scope+ option. - - var options = {scope: "activerecord.attributes.user"}; - - I18n.t("name", options); - I18n.t("email", options); - I18n.t("username", options); - -You also provide an array as scope. - - // use the greetings.hello scope - I18n.t(["greetings", "hello"]); - -==== Number formatting - -Similar to Rails helpers, you have localized number and currency formatting. - - I18n.l("currency", 1990.99); - // $1,990.99 - - I18n.l("number", 1990.99); - // 1,990.99 - - I18n.l("percentage", 123.45); - // 123.450% - -To have more control over number formatting, you can use the I18n.toNumber, I18n.toPercentage, I18n.toCurrency and I18n.toHumanSize functions. - - I18n.toNumber(1000); // 1,000.000 - I18n.toCurrency(1000); // $1,000.00 - I18n.toPercentage(100); // 100.000% - -The +toNumber+ and +toPercentage+ functions accept the following options: - -* +precision+: defaults to 3 -* +separator+: defaults to . -* +delimiter+: defaults to , -* +strip_insignificant_zeros+: defaults to false - -See some number formatting examples: - - I18n.toNumber(1000, {precision: 0}); // 1,000 - I18n.toNumber(1000, {delimiter: ".", separator: ","}); // 1.000,000 - I18n.toNumber(1000, {delimiter: ".", precision: 0}); // 1.000 - -The +toCurrency+ function accepts the following options: - -* +precision+: sets the level of precision -* +separator+: sets the separator between the units -* +delimiter+: sets the thousands delimiter -* +format+: sets the format of the output string -* +unit+: sets the denomination of the currency -* +strip_insignificant_zeros+: defaults to false - -You can provide only the options you want to override: - - I18n.toCurrency(1000, {precision: 0}); // $1,000 - -The +toHumanSize+ function accepts the following options: - -* +precision+: defaults to 1 -* +separator+: defaults to . -* +delimiter+: defaults to "" -* +strip_insignificant_zeros+: defaults to false -* +format+: defaults to %n%u - - I18n.toHumanSize(1234); // 1KB - I18n.toHumanSize(1234 * 1024); // 1MB - -==== Date formatting - - // accepted formats - I18n.l("date.formats.short", "2009-09-18"); // yyyy-mm-dd - I18n.l("time.formats.short", "2009-09-18 23:12:43"); // yyyy-mm-dd hh:mm:ss - I18n.l("time.formats.short", "2009-11-09T18:10:34"); // JSON format with local Timezone (part of ISO-8601) - I18n.l("time.formats.short", "2009-11-09T18:10:34Z"); // JSON format in UTC (part of ISO-8601) - I18n.l("date.formats.short", 1251862029000); // Epoch time - I18n.l("date.formats.short", "09/18/2009"); // mm/dd/yyyy - I18n.l("date.formats.short", (new Date())); // Date object - -If you prefer, you can use the I18n.strftime function to format dates. - - var date = new Date(); - I18n.strftime(date, "%d/%m/%Y"); - -The accepted formats are: - - %a - The abbreviated weekday name (Sun) - %A - The full weekday name (Sunday) - %b - The abbreviated month name (Jan) - %B - The full month name (January) - %c - The preferred local date and time representation - %d - Day of the month (01..31) - %-d - Day of the month (1..31) - %H - Hour of the day, 24-hour clock (00..23) - %-H - Hour of the day, 24-hour clock (0..23) - %I - Hour of the day, 12-hour clock (01..12) - %-I - Hour of the day, 12-hour clock (1..12) - %m - Month of the year (01..12) - %-m - Month of the year (1..12) - %M - Minute of the hour (00..59) - %-M - Minute of the hour (0..59) - %p - Meridian indicator (AM or PM) - %S - Second of the minute (00..60) - %-S - Second of the minute (0..60) - %w - Day of the week (Sunday is 0, 0..6) - %y - Year without a century (00..99) - %-y - Year without a century (0..99) - %Y - Year with century - %z - Timezone offset (+0545) - -Check out vendor/plugins/i18n-js/spec/i18n_spec.js for more examples! - -== Using I18nJS with other languages (Python, PHP, ...) - -The JavaScript library is language agnostic; so you can use it with PHP, Python, [you favorite language here]. -The only requirement is that you need to set the +translations+ attribute like following: - - I18n.translations = {}; - - I18n.translations["en"] = { - message: "Some special message for you" - } - - I18n.translations["pt-BR"] = { - message: "Uma mensagem especial para você" - } - -== Maintainer - -* Nando Vieira - http://simplesideias.com.br -* Sébastien Grosjean - http://github.com/ZenCocoon - -== Contributing - -Once you've made your great commits: - -1. Fork[http://help.github.com/forking/] I18n-JS -2. Create a topic branch - git checkout -b my_branch -3. Push to your branch - git push origin my_branch -4. Create an Issue[http://github.com/fnando/i18n-js/issues] with a link to your branch -5. That's it! - -Please respect the indentation rules. And use 2 spaces, not tabs. - -=== Running tests - -First, install all dependencies. - - bundle install - -Then just run rake spec. - -== License - -(The MIT License) - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Rakefile b/Rakefile index 25843c3f..bf041de6 100644 --- a/Rakefile +++ b/Rakefile @@ -1,13 +1,25 @@ +require "appraisal" +require "rubygems" require "bundler" -Bundler::GemHelper.install_tasks +require "rspec/core/rake_task" -require "spec_js/rake_task" -SpecJs::RakeTask.new do |t| - t.env_js = false -end +Bundler::GemHelper.install_tasks -require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:"spec:ruby") +desc "Run JavaScript specs" +task "spec:js" do + # Need to call `exit!` manually to propogate exit status + system "npm", "test" or exit!(1) +end + desc "Run all specs" -task :spec => [:"spec:ruby", :"spec:js"] +task :spec => ["spec:ruby", "spec:js"] + +if !ENV["APPRAISAL_INITIALIZED"] && !ENV["TRAVIS"] + task :default do + sh "appraisal install && rake appraisal spec" + end +else + task :default => :spec +end diff --git a/app/assets/javascripts/i18n.js b/app/assets/javascripts/i18n.js new file mode 100644 index 00000000..890192d5 --- /dev/null +++ b/app/assets/javascripts/i18n.js @@ -0,0 +1,1095 @@ +// I18n.js +// ======= +// +// This small library provides the Rails I18n API on the Javascript. +// You don't actually have to use Rails (or even Ruby) to use I18n.js. +// Just make sure you export all translations in an object like this: +// +// I18n.translations.en = { +// hello: "Hello World" +// }; +// +// See tests for specific formatting like numbers and dates. +// + +// Using UMD pattern from +// https://github.com/umdjs/umd#regular-module +// `returnExports.js` version +;(function (root, factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define("i18n", function(){ return factory(root);}); + } else if (typeof module === 'object' && module.exports) { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(root); + } else { + // Browser globals (root is window) + root.I18n = factory(root); + } +}(this, function(global) { + "use strict"; + + // Use previously defined object if exists in current scope + var I18n = global && global.I18n || {}; + + // Just cache the Array#slice function. + var slice = Array.prototype.slice; + + // Apply number padding. + var padding = function(number) { + return ("0" + number.toString()).substr(-2); + }; + + // Improved toFixed number rounding function with support for unprecise floating points + // JavaScript's standard toFixed function does not round certain numbers correctly (for example 0.105 with precision 2). + var toFixed = function(number, precision) { + return decimalAdjust('round', number, -precision).toFixed(precision); + }; + + // Is a given variable an object? + // Borrowed from Underscore.js + var isObject = function(obj) { + var type = typeof obj; + return type === 'function' || type === 'object' + }; + + var isFunction = function(func) { + var type = typeof func; + return type === 'function' + }; + + // Check if value is different than undefined and null; + var isSet = function(value) { + return typeof(value) !== 'undefined' && value !== null; + }; + + // Is a given value an array? + // Borrowed from Underscore.js + var isArray = function(val) { + if (Array.isArray) { + return Array.isArray(val); + } + return Object.prototype.toString.call(val) === '[object Array]'; + }; + + var isString = function(val) { + return typeof val === 'string' || Object.prototype.toString.call(val) === '[object String]'; + }; + + var isNumber = function(val) { + return typeof val === 'number' || Object.prototype.toString.call(val) === '[object Number]'; + }; + + var isBoolean = function(val) { + return val === true || val === false; + }; + + var isNull = function(val) { + return val === null; + }; + + var decimalAdjust = function(type, value, exp) { + // If the exp is undefined or zero... + if (typeof exp === 'undefined' || +exp === 0) { + return Math[type](value); + } + value = +value; + exp = +exp; + // If the value is not a number or the exp is not an integer... + if (isNaN(value) || !(typeof exp === 'number' && exp % 1 === 0)) { + return NaN; + } + // Shift + value = value.toString().split('e'); + value = Math[type](+(value[0] + 'e' + (value[1] ? (+value[1] - exp) : -exp))); + // Shift back + value = value.toString().split('e'); + return +(value[0] + 'e' + (value[1] ? (+value[1] + exp) : exp)); + }; + + var lazyEvaluate = function(message, scope) { + if (isFunction(message)) { + return message(scope); + } else { + return message; + } + }; + + var merge = function (dest, obj) { + var key, value; + for (key in obj) if (obj.hasOwnProperty(key)) { + value = obj[key]; + if (isString(value) || isNumber(value) || isBoolean(value) || isArray(value) || isNull(value)) { + dest[key] = value; + } else { + if (dest[key] == null) dest[key] = {}; + merge(dest[key], value); + } + } + return dest; + }; + + // Set default days/months translations. + var DATE = { + day_names: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + , abbr_day_names: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + , month_names: [null, "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] + , abbr_month_names: [null, "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + , meridian: ["AM", "PM"] + }; + + // Set default number format. + var NUMBER_FORMAT = { + precision: 3 + , separator: "." + , delimiter: "," + , strip_insignificant_zeros: false + }; + + // Set default currency format. + var CURRENCY_FORMAT = { + unit: "$" + , precision: 2 + , format: "%u%n" + , sign_first: true + , delimiter: "," + , separator: "." + }; + + // Set default percentage format. + var PERCENTAGE_FORMAT = { + unit: "%" + , precision: 3 + , format: "%n%u" + , separator: "." + , delimiter: "" + }; + + // Set default size units. + var SIZE_UNITS = [null, "kb", "mb", "gb", "tb"]; + + // Other default options + var DEFAULT_OPTIONS = { + // Set default locale. This locale will be used when fallback is enabled and + // the translation doesn't exist in a particular locale. + defaultLocale: "en" + // Set the current locale to `en`. + , locale: "en" + // Set the translation key separator. + , defaultSeparator: "." + // Set the placeholder format. Accepts `{{placeholder}}` and `%{placeholder}`. + , placeholder: /(?:\{\{|%\{)(.*?)(?:\}\}?)/gm + // Set if engine should fallback to the default locale when a translation + // is missing. + , fallbacks: false + // Set the default translation object. + , translations: {} + // Set missing translation behavior. 'message' will display a message + // that the translation is missing, 'guess' will try to guess the string + , missingBehaviour: 'message' + // if you use missingBehaviour with 'message', but want to know that the + // string is actually missing for testing purposes, you can prefix the + // guessed string by setting the value here. By default, no prefix! + , missingTranslationPrefix: '' + }; + + // Set default locale. This locale will be used when fallback is enabled and + // the translation doesn't exist in a particular locale. + I18n.reset = function() { + var key; + for (key in DEFAULT_OPTIONS) { + this[key] = DEFAULT_OPTIONS[key]; + } + }; + + // Much like `reset`, but only assign options if not already assigned + I18n.initializeOptions = function() { + var key; + for (key in DEFAULT_OPTIONS) if (!isSet(this[key])) { + this[key] = DEFAULT_OPTIONS[key]; + } + }; + I18n.initializeOptions(); + + // Return a list of all locales that must be tried before returning the + // missing translation message. By default, this will consider the inline option, + // current locale and fallback locale. + // + // I18n.locales.get("de-DE"); + // // ["de-DE", "de", "en"] + // + // You can define custom rules for any locale. Just make sure you return a array + // containing all locales. + // + // // Default the Wookie locale to English. + // I18n.locales["wk"] = function(locale) { + // return ["en"]; + // }; + // + I18n.locales = {}; + + // Retrieve locales based on inline locale, current locale or default to + // I18n's detection. + I18n.locales.get = function(locale) { + var result = this[locale] || this[I18n.locale] || this["default"]; + + if (isFunction(result)) { + result = result(locale); + } + + if (isArray(result) === false) { + result = [result]; + } + + return result; + }; + + // The default locale list. + I18n.locales["default"] = function(locale) { + var locales = [] + , list = [] + ; + + // Handle the inline locale option that can be provided to + // the `I18n.t` options. + if (locale) { + locales.push(locale); + } + + // Add the current locale to the list. + if (!locale && I18n.locale) { + locales.push(I18n.locale); + } + + // Add the default locale if fallback strategy is enabled. + if (I18n.fallbacks && I18n.defaultLocale) { + locales.push(I18n.defaultLocale); + } + + // Locale code format 1: + // According to RFC4646 (http://www.ietf.org/rfc/rfc4646.txt) + // language codes for Traditional Chinese should be `zh-Hant` + // + // But due to backward compatibility + // We use older version of IETF language tag + // @see http://www.w3.org/TR/html401/struct/dirlang.html + // @see http://en.wikipedia.org/wiki/IETF_language_tag + // + // Format: `language-code = primary-code ( "-" subcode )*` + // + // primary-code uses ISO639-1 + // @see http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes + // @see http://www.iso.org/iso/home/standards/language_codes.htm + // + // subcode uses ISO 3166-1 alpha-2 + // @see http://en.wikipedia.org/wiki/ISO_3166 + // @see http://www.iso.org/iso/country_codes.htm + // + // @note + // subcode can be in upper case or lower case + // defining it in upper case is a convention only + + + // Locale code format 2: + // Format: `code = primary-code ( "-" region-code )*` + // primary-code uses ISO 639-1 + // script-code uses ISO 15924 + // region-code uses ISO 3166-1 alpha-2 + // Example: zh-Hant-TW, en-HK, zh-Hant-CN + // + // It is similar to RFC4646 (or actually the same), + // but seems to be limited to language, script, region + + // Compute each locale with its country code. + // So this will return an array containing + // `de-DE` and `de` + // or + // `zh-hans-tw`, `zh-hans`, `zh` + // locales. + locales.forEach(function(locale) { + var localeParts = locale.split("-"); + var firstFallback = null; + var secondFallback = null; + if (localeParts.length === 3) { + firstFallback = [ + localeParts[0], + localeParts[1] + ].join("-"); + secondFallback = localeParts[0]; + } + else if (localeParts.length === 2) { + firstFallback = localeParts[0]; + } + + if (list.indexOf(locale) === -1) { + list.push(locale); + } + + if (! I18n.fallbacks) { + return; + } + + [ + firstFallback, + secondFallback + ].forEach(function(nullableFallbackLocale) { + // We don't want null values + if (typeof nullableFallbackLocale === "undefined") { return; } + if (nullableFallbackLocale === null) { return; } + // We don't want duplicate values + // + // Comparing with `locale` first is faster than + // checking whether value's presence in the list + if (nullableFallbackLocale === locale) { return; } + if (list.indexOf(nullableFallbackLocale) !== -1) { return; } + + list.push(nullableFallbackLocale); + }); + }); + + // No locales set? English it is. + if (!locales.length) { + locales.push("en"); + } + + return list; + }; + + // Hold pluralization rules. + I18n.pluralization = {}; + + // Return the pluralizer for a specific locale. + // If no specify locale is found, then I18n's default will be used. + I18n.pluralization.get = function(locale) { + return this[locale] || this[I18n.locale] || this["default"]; + }; + + // The default pluralizer rule. + // It detects the `zero`, `one`, and `other` scopes. + I18n.pluralization["default"] = function(count) { + switch (count) { + case 0: return ["zero", "other"]; + case 1: return ["one"]; + default: return ["other"]; + } + }; + + // Return current locale. If no locale has been set, then + // the current locale will be the default locale. + I18n.currentLocale = function() { + return this.locale || this.defaultLocale; + }; + + // Check if value is different than undefined and null; + I18n.isSet = isSet; + + // Find and process the translation using the provided scope and options. + // This is used internally by some functions and should not be used as an + // public API. + I18n.lookup = function(scope, options) { + options = options || {}; + + var locales = this.locales.get(options.locale).slice() + , locale + , scopes + , fullScope + , translations + ; + + fullScope = this.getFullScope(scope, options); + + while (locales.length) { + locale = locales.shift(); + scopes = fullScope.split(options.separator || this.defaultSeparator); + translations = this.translations[locale]; + + if (!translations) { + continue; + } + while (scopes.length) { + translations = translations[scopes.shift()]; + + if (translations === undefined || translations === null) { + break; + } + } + + if (translations !== undefined && translations !== null) { + return translations; + } + } + + if (isSet(options.defaultValue)) { + return lazyEvaluate(options.defaultValue, scope); + } + }; + + // lookup pluralization rule key into translations + I18n.pluralizationLookupWithoutFallback = function(count, locale, translations) { + var pluralizer = this.pluralization.get(locale) + , pluralizerKeys = pluralizer(count) + , pluralizerKey + , message; + + if (isObject(translations)) { + while (pluralizerKeys.length) { + pluralizerKey = pluralizerKeys.shift(); + if (isSet(translations[pluralizerKey])) { + message = translations[pluralizerKey]; + break; + } + } + } + + return message; + }; + + // Lookup dedicated to pluralization + I18n.pluralizationLookup = function(count, scope, options) { + options = options || {}; + var locales = this.locales.get(options.locale).slice() + , locale + , scopes + , translations + , message + ; + scope = this.getFullScope(scope, options); + + while (locales.length) { + locale = locales.shift(); + scopes = scope.split(options.separator || this.defaultSeparator); + translations = this.translations[locale]; + + if (!translations) { + continue; + } + + while (scopes.length) { + translations = translations[scopes.shift()]; + if (!isObject(translations)) { + break; + } + if (scopes.length === 0) { + message = this.pluralizationLookupWithoutFallback(count, locale, translations); + } + } + if (typeof message !== "undefined" && message !== null) { + break; + } + } + + if (typeof message === "undefined" || message === null) { + if (isSet(options.defaultValue)) { + if (isObject(options.defaultValue)) { + message = this.pluralizationLookupWithoutFallback(count, options.locale, options.defaultValue); + } else { + message = options.defaultValue; + } + translations = options.defaultValue; + } + } + + return { message: message, translations: translations }; + }; + + // Rails changed the way the meridian is stored. + // It started with `date.meridian` returning an array, + // then it switched to `time.am` and `time.pm`. + // This function abstracts this difference and returns + // the correct meridian or the default value when none is provided. + I18n.meridian = function() { + var time = this.lookup("time"); + var date = this.lookup("date"); + + if (time && time.am && time.pm) { + return [time.am, time.pm]; + } else if (date && date.meridian) { + return date.meridian; + } else { + return DATE.meridian; + } + }; + + // Merge serveral hash options, checking if value is set before + // overwriting any value. The precedence is from left to right. + // + // I18n.prepareOptions({name: "John Doe"}, {name: "Mary Doe", role: "user"}); + // #=> {name: "John Doe", role: "user"} + // + I18n.prepareOptions = function() { + var args = slice.call(arguments) + , options = {} + , subject + ; + + while (args.length) { + subject = args.shift(); + + if (typeof(subject) != "object") { + continue; + } + + for (var attr in subject) { + if (!subject.hasOwnProperty(attr)) { + continue; + } + + if (isSet(options[attr])) { + continue; + } + + options[attr] = subject[attr]; + } + } + + return options; + }; + + // Generate a list of translation options for default fallbacks. + // `defaultValue` is also deleted from options as it is returned as part of + // the translationOptions array. + I18n.createTranslationOptions = function(scope, options) { + var translationOptions = [{scope: scope}]; + + // Defaults should be an array of hashes containing either + // fallback scopes or messages + if (isSet(options.defaults)) { + translationOptions = translationOptions.concat(options.defaults); + } + + // Maintain support for defaultValue. Since it is always a message + // insert it in to the translation options as such. + if (isSet(options.defaultValue)) { + translationOptions.push({ message: options.defaultValue }); + } + + return translationOptions; + }; + + // Translate the given scope with the provided options. + I18n.translate = function(scope, options) { + options = options || {}; + + var translationOptions = this.createTranslationOptions(scope, options); + + var translation; + var usedScope = scope; + + var optionsWithoutDefault = this.prepareOptions(options) + delete optionsWithoutDefault.defaultValue + + // Iterate through the translation options until a translation + // or message is found. + var translationFound = + translationOptions.some(function(translationOption) { + if (isSet(translationOption.scope)) { + usedScope = translationOption.scope; + translation = this.lookup(usedScope, optionsWithoutDefault); + } else if (isSet(translationOption.message)) { + translation = lazyEvaluate(translationOption.message, scope); + } + + if (translation !== undefined && translation !== null) { + return true; + } + }, this); + + if (!translationFound) { + return this.missingTranslation(scope, options); + } + + if (typeof(translation) === "string") { + translation = this.interpolate(translation, options); + } else if (isArray(translation)) { + translation = translation.map(function(t) { + return (typeof(t) === "string" ? this.interpolate(t, options) : t); + }, this); + } else if (isObject(translation) && isSet(options.count)) { + translation = this.pluralize(options.count, usedScope, options); + } + + return translation; + }; + + // This function interpolates the all variables in the given message. + I18n.interpolate = function(message, options) { + if (message == null) { + return message; + } + + options = options || {}; + var matches = message.match(this.placeholder) + , placeholder + , value + , name + , regex + ; + + if (!matches) { + return message; + } + + while (matches.length) { + placeholder = matches.shift(); + name = placeholder.replace(this.placeholder, "$1"); + + if (isSet(options[name])) { + value = options[name].toString().replace(/\$/gm, "_#$#_"); + } else if (name in options) { + value = this.nullPlaceholder(placeholder, message, options); + } else { + value = this.missingPlaceholder(placeholder, message, options); + } + + regex = new RegExp(placeholder.replace(/{/gm, "\\{").replace(/}/gm, "\\}")); + message = message.replace(regex, value); + } + + return message.replace(/_#\$#_/g, "$"); + }; + + // Pluralize the given scope using the `count` value. + // The pluralized translation may have other placeholders, + // which will be retrieved from `options`. + I18n.pluralize = function(count, scope, options) { + options = this.prepareOptions({count: String(count)}, options) + var pluralizer, result; + + result = this.pluralizationLookup(count, scope, options); + if (typeof result.translations === "undefined" || result.translations == null) { + return this.missingTranslation(scope, options); + } + + if (typeof result.message !== "undefined" && result.message != null) { + return this.interpolate(result.message, options); + } + else { + pluralizer = this.pluralization.get(options.locale); + return this.missingTranslation(scope + '.' + pluralizer(count)[0], options); + } + }; + + // Return a missing translation message for the given parameters. + I18n.missingTranslation = function(scope, options) { + //guess intended string + if(this.missingBehaviour === 'guess'){ + //get only the last portion of the scope + var s = scope.split('.').slice(-1)[0]; + //replace underscore with space && camelcase with space and lowercase letter + return (this.missingTranslationPrefix.length > 0 ? this.missingTranslationPrefix : '') + + s.replace(/_/g,' ').replace(/([a-z])([A-Z])/g, + function(match, p1, p2) {return p1 + ' ' + p2.toLowerCase()} ); + } + + var localeForTranslation = (options != null && options.locale != null) ? options.locale : this.currentLocale(); + var fullScope = this.getFullScope(scope, options); + var fullScopeWithLocale = [localeForTranslation, fullScope].join(options.separator || this.defaultSeparator); + + return '[missing "' + fullScopeWithLocale + '" translation]'; + }; + + // Return a missing placeholder message for given parameters + I18n.missingPlaceholder = function(placeholder, message, options) { + return "[missing " + placeholder + " value]"; + }; + + I18n.nullPlaceholder = function() { + return I18n.missingPlaceholder.apply(I18n, arguments); + }; + + // Format number using localization rules. + // The options will be retrieved from the `number.format` scope. + // If this isn't present, then the following options will be used: + // + // - `precision`: `3` + // - `separator`: `"."` + // - `delimiter`: `","` + // - `strip_insignificant_zeros`: `false` + // + // You can also override these options by providing the `options` argument. + // + I18n.toNumber = function(number, options) { + options = this.prepareOptions( + options + , this.lookup("number.format") + , NUMBER_FORMAT + ); + + var negative = number < 0 + , string = toFixed(Math.abs(number), options.precision).toString() + , parts = string.split(".") + , precision + , buffer = [] + , formattedNumber + , format = options.format || "%n" + , sign = negative ? "-" : "" + ; + + number = parts[0]; + precision = parts[1]; + + while (number.length > 0) { + buffer.unshift(number.substr(Math.max(0, number.length - 3), 3)); + number = number.substr(0, number.length -3); + } + + formattedNumber = buffer.join(options.delimiter); + + if (options.strip_insignificant_zeros && precision) { + precision = precision.replace(/0+$/, ""); + } + + if (options.precision > 0 && precision) { + formattedNumber += options.separator + precision; + } + + if (options.sign_first) { + format = "%s" + format; + } + else { + format = format.replace("%n", "%s%n"); + } + + formattedNumber = format + .replace("%u", options.unit) + .replace("%n", formattedNumber) + .replace("%s", sign) + ; + + return formattedNumber; + }; + + // Format currency with localization rules. + // The options will be retrieved from the `number.currency.format` and + // `number.format` scopes, in that order. + // + // Any missing option will be retrieved from the `I18n.toNumber` defaults and + // the following options: + // + // - `unit`: `"$"` + // - `precision`: `2` + // - `format`: `"%u%n"` + // - `delimiter`: `","` + // - `separator`: `"."` + // + // You can also override these options by providing the `options` argument. + // + I18n.toCurrency = function(number, options) { + options = this.prepareOptions( + options + , this.lookup("number.currency.format", options) + , this.lookup("number.format", options) + , CURRENCY_FORMAT + ); + + return this.toNumber(number, options); + }; + + // Localize several values. + // You can provide the following scopes: `currency`, `number`, or `percentage`. + // If you provide a scope that matches the `/^(date|time)/` regular expression + // then the `value` will be converted by using the `I18n.toTime` function. + // + // It will default to the value's `toString` function. + // + I18n.localize = function(scope, value, options) { + options || (options = {}); + + switch (scope) { + case "currency": + return this.toCurrency(value, options); + case "number": + scope = this.lookup("number.format", options); + return this.toNumber(value, scope); + case "percentage": + return this.toPercentage(value, options); + default: + var localizedValue; + + if (scope.match(/^(date|time)/)) { + localizedValue = this.toTime(scope, value, options); + } else { + localizedValue = value.toString(); + } + + return this.interpolate(localizedValue, options); + } + }; + + // Parse a given `date` string into a JavaScript Date object. + // This function is time zone aware. + // + // The following string formats are recognized: + // + // yyyy-mm-dd + // yyyy-mm-dd[ T]hh:mm::ss + // yyyy-mm-dd[ T]hh:mm::ss + // yyyy-mm-dd[ T]hh:mm::ssZ + // yyyy-mm-dd[ T]hh:mm::ss+0000 + // yyyy-mm-dd[ T]hh:mm::ss+00:00 + // yyyy-mm-dd[ T]hh:mm::ss.123Z + // + I18n.parseDate = function(date) { + var matches, convertedDate, fraction; + // A date input of `null` or `undefined` will be returned as-is + if (date == null) { + return date; + } + // we have a date, so just return it. + if (typeof(date) === "object") { + return date; + } + + matches = date.toString().match(/(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})([\.,]\d{1,3})?)?(Z|\+00:?00)?/); + + if (matches) { + for (var i = 1; i <= 6; i++) { + matches[i] = parseInt(matches[i], 10) || 0; + } + + // month starts on 0 + matches[2] -= 1; + + fraction = matches[7] ? 1000 * ("0" + matches[7]) : null; + + if (matches[8]) { + convertedDate = new Date(Date.UTC(matches[1], matches[2], matches[3], matches[4], matches[5], matches[6], fraction)); + } else { + convertedDate = new Date(matches[1], matches[2], matches[3], matches[4], matches[5], matches[6], fraction); + } + } else if (typeof(date) == "number") { + // UNIX timestamp + convertedDate = new Date(); + convertedDate.setTime(date); + } else if (date.match(/([A-Z][a-z]{2}) ([A-Z][a-z]{2}) (\d+) (\d+:\d+:\d+) ([+-]\d+) (\d+)/)) { + // This format `Wed Jul 20 13:03:39 +0000 2011` is parsed by + // webkit/firefox, but not by IE, so we must parse it manually. + convertedDate = new Date(); + convertedDate.setTime(Date.parse([ + RegExp.$1, RegExp.$2, RegExp.$3, RegExp.$6, RegExp.$4, RegExp.$5 + ].join(" "))); + } else if (date.match(/\d+ \d+:\d+:\d+ [+-]\d+ \d+/)) { + // a valid javascript format with timezone info + convertedDate = new Date(); + convertedDate.setTime(Date.parse(date)); + } else { + // an arbitrary javascript string + convertedDate = new Date(); + convertedDate.setTime(Date.parse(date)); + } + + return convertedDate; + }; + + // Formats time according to the directives in the given format string. + // The directives begins with a percent (%) character. Any text not listed as a + // directive will be passed through to the output string. + // + // The accepted formats are: + // + // %a - The abbreviated weekday name (Sun) + // %A - The full weekday name (Sunday) + // %b - The abbreviated month name (Jan) + // %B - The full month name (January) + // %c - The preferred local date and time representation + // %d - Day of the month (01..31) + // %-d - Day of the month (1..31) + // %H - Hour of the day, 24-hour clock (00..23) + // %-H/%k - Hour of the day, 24-hour clock (0..23) + // %I - Hour of the day, 12-hour clock (01..12) + // %-I/%l - Hour of the day, 12-hour clock (1..12) + // %m - Month of the year (01..12) + // %-m - Month of the year (1..12) + // %M - Minute of the hour (00..59) + // %-M - Minute of the hour (0..59) + // %p - Meridian indicator (AM or PM) + // %P - Meridian indicator (am or pm) + // %S - Second of the minute (00..60) + // %-S - Second of the minute (0..60) + // %w - Day of the week (Sunday is 0, 0..6) + // %y - Year without a century (00..99) + // %-y - Year without a century (0..99) + // %Y - Year with century + // %z/%Z - Timezone offset (+0545) + // + I18n.strftime = function(date, format, options) { + var options = this.lookup("date", options) + , meridianOptions = I18n.meridian() + ; + + if (!options) { + options = {}; + } + + options = this.prepareOptions(options, DATE); + + if (isNaN(date.getTime())) { + throw new Error('I18n.strftime() requires a valid date object, but received an invalid date.'); + } + + var weekDay = date.getDay() + , day = date.getDate() + , year = date.getFullYear() + , month = date.getMonth() + 1 + , hour = date.getHours() + , hour12 = hour + , meridian = hour > 11 ? 1 : 0 + , secs = date.getSeconds() + , mins = date.getMinutes() + , offset = date.getTimezoneOffset() + , absOffsetHours = Math.floor(Math.abs(offset / 60)) + , absOffsetMinutes = Math.abs(offset) - (absOffsetHours * 60) + , timezoneoffset = (offset > 0 ? "-" : "+") + + (absOffsetHours.toString().length < 2 ? "0" + absOffsetHours : absOffsetHours) + + (absOffsetMinutes.toString().length < 2 ? "0" + absOffsetMinutes : absOffsetMinutes) + ; + + if (hour12 > 12) { + hour12 = hour12 - 12; + } else if (hour12 === 0) { + hour12 = 12; + } + + format = format.replace("%a", options.abbr_day_names[weekDay]); + format = format.replace("%A", options.day_names[weekDay]); + format = format.replace("%b", options.abbr_month_names[month]); + format = format.replace("%B", options.month_names[month]); + format = format.replace("%d", padding(day)); + format = format.replace("%e", day); + format = format.replace("%-d", day); + format = format.replace("%H", padding(hour)); + format = format.replace("%-H", hour); + format = format.replace("%k", hour); + format = format.replace("%I", padding(hour12)); + format = format.replace("%-I", hour12); + format = format.replace("%l", hour12); + format = format.replace("%m", padding(month)); + format = format.replace("%-m", month); + format = format.replace("%M", padding(mins)); + format = format.replace("%-M", mins); + format = format.replace("%p", meridianOptions[meridian]); + format = format.replace("%P", meridianOptions[meridian].toLowerCase()); + format = format.replace("%S", padding(secs)); + format = format.replace("%-S", secs); + format = format.replace("%w", weekDay); + format = format.replace("%y", padding(year)); + format = format.replace("%-y", padding(year).replace(/^0+/, "")); + format = format.replace("%Y", year); + format = format.replace("%z", timezoneoffset); + format = format.replace("%Z", timezoneoffset); + + return format; + }; + + // Convert the given dateString into a formatted date. + I18n.toTime = function(scope, dateString, options) { + var date = this.parseDate(dateString) + , format = this.lookup(scope, options) + ; + + // A date input of `null` or `undefined` will be returned as-is + if (date == null) { + return date; + } + + var date_string = date.toString() + if (date_string.match(/invalid/i)) { + return date_string; + } + + if (!format) { + return date_string; + } + + return this.strftime(date, format, options); + }; + + // Convert a number into a formatted percentage value. + I18n.toPercentage = function(number, options) { + options = this.prepareOptions( + options + , this.lookup("number.percentage.format", options) + , this.lookup("number.format", options) + , PERCENTAGE_FORMAT + ); + + return this.toNumber(number, options); + }; + + // Convert a number into a readable size representation. + I18n.toHumanSize = function(number, options) { + var kb = 1024 + , size = number + , iterations = 0 + , unit + , precision + , fullScope + ; + + while (size >= kb && iterations < 4) { + size = size / kb; + iterations += 1; + } + + if (iterations === 0) { + fullScope = this.getFullScope("number.human.storage_units.units.byte", options); + unit = this.t(fullScope, {count: size}); + precision = 0; + } else { + fullScope = this.getFullScope("number.human.storage_units.units." + SIZE_UNITS[iterations], options); + unit = this.t(fullScope); + precision = (size - Math.floor(size) === 0) ? 0 : 1; + } + + options = this.prepareOptions( + options + , {unit: unit, precision: precision, format: "%n%u", delimiter: ""} + ); + + return this.toNumber(size, options); + }; + + I18n.getFullScope = function(scope, options) { + options = options || {}; + + // Deal with the scope as an array. + if (isArray(scope)) { + scope = scope.join(options.separator || this.defaultSeparator); + } + + // Deal with the scope option provided through the second argument. + // + // I18n.t('hello', {scope: 'greetings'}); + // + if (options.scope) { + scope = [options.scope, scope].join(options.separator || this.defaultSeparator); + } + + return scope; + }; + /** + * Merge obj1 with obj2 (shallow merge), without modifying inputs + * @param {Object} obj1 + * @param {Object} obj2 + * @returns {Object} Merged values of obj1 and obj2 + * + * In order to support ES3, `Object.prototype.hasOwnProperty.call` is used + * Idea is from: + * https://stackoverflow.com/questions/8157700/object-has-no-hasownproperty-method-i-e-its-undefined-ie8 + */ + I18n.extend = function ( obj1, obj2 ) { + if (typeof(obj1) === "undefined" && typeof(obj2) === "undefined") { + return {}; + } + return merge(obj1, obj2); + }; + + // Set aliases, so we can save some typing. + I18n.t = I18n.translate.bind(I18n); + I18n.l = I18n.localize.bind(I18n); + I18n.p = I18n.pluralize.bind(I18n); + + return I18n; +})); diff --git a/app/assets/javascripts/i18n/filtered.js.erb b/app/assets/javascripts/i18n/filtered.js.erb new file mode 100644 index 00000000..5570bb4f --- /dev/null +++ b/app/assets/javascripts/i18n/filtered.js.erb @@ -0,0 +1,23 @@ +<%# encoding: utf-8 %> + +// Using UMD pattern from +// https://github.com/umdjs/umd#regular-module +// `returnExports.js` version +;(function (root, factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(["i18n"], factory); + } else if (typeof module === 'object' && module.exports) { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + factory(require("i18n")); + } else { + // Browser globals (root is window) + factory(root.I18n); + } +}(this, function(I18n) { + "use strict"; + + I18n.translations = <%= I18n::JS.filtered_translations.to_json %>; +})); diff --git a/app/assets/javascripts/i18n/shims.js b/app/assets/javascripts/i18n/shims.js new file mode 100644 index 00000000..a219870c --- /dev/null +++ b/app/assets/javascripts/i18n/shims.js @@ -0,0 +1,240 @@ +// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/indexOf +if (!Array.prototype.indexOf) { + Array.prototype.indexOf = function (searchElement /*, fromIndex */ ) { + "use strict"; + if (this == null) { + throw new TypeError(); + } + var t = Object(this); + var len = t.length >>> 0; + if (len === 0) { + return -1; + } + var n = 0; + if (arguments.length > 1) { + n = Number(arguments[1]); + if (n != n) { // shortcut for verifying if it's NaN + n = 0; + } else if (n != 0 && n != Infinity && n != -Infinity) { + n = (n > 0 || -1) * Math.floor(Math.abs(n)); + } + } + if (n >= len) { + return -1; + } + var k = n >= 0 ? n : Math.max(len - Math.abs(n), 0); + for (; k < len; k++) { + if (k in t && t[k] === searchElement) { + return k; + } + } + return -1; + } +} + +// Production steps of ECMA-262, Edition 5, 15.4.4.18 +// Reference: http://es5.github.com/#x15.4.4.18 +// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/forEach +if ( !Array.prototype.forEach ) { + + Array.prototype.forEach = function forEach( callback, thisArg ) { + + var T, k; + + if ( this == null ) { + throw new TypeError( "this is null or not defined" ); + } + + // 1. Let O be the result of calling ToObject passing the |this| value as the argument. + var O = Object(this); + + // 2. Let lenValue be the result of calling the Get internal method of O with the argument "length". + // 3. Let len be ToUint32(lenValue). + var len = O.length >>> 0; // Hack to convert O.length to a UInt32 + + // 4. If IsCallable(callback) is false, throw a TypeError exception. + // See: http://es5.github.com/#x9.11 + if ( {}.toString.call(callback) !== "[object Function]" ) { + throw new TypeError( callback + " is not a function" ); + } + + // 5. If thisArg was supplied, let T be thisArg; else let T be undefined. + if ( thisArg ) { + T = thisArg; + } + + // 6. Let k be 0 + k = 0; + + // 7. Repeat, while k < len + while( k < len ) { + + var kValue; + + // a. Let Pk be ToString(k). + // This is implicit for LHS operands of the in operator + // b. Let kPresent be the result of calling the HasProperty internal method of O with argument Pk. + // This step can be combined with c + // c. If kPresent is true, then + if ( Object.prototype.hasOwnProperty.call(O, k) ) { + + // i. Let kValue be the result of calling the Get internal method of O with argument Pk. + kValue = O[ k ]; + + // ii. Call the Call internal method of callback with T as the this value and + // argument list containing kValue, k, and O. + callback.call( T, kValue, k, O ); + } + // d. Increase k by 1. + k++; + } + // 8. return undefined + }; +} + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/some +if (!Array.prototype.some) +{ + Array.prototype.some = function(fun /*, thisArg */) + { + 'use strict'; + + if (this === void 0 || this === null) + throw new TypeError(); + + var t = Object(this); + var len = t.length >>> 0; + if (typeof fun !== 'function') + throw new TypeError(); + + var thisArg = arguments.length >= 2 ? arguments[1] : void 0; + for (var i = 0; i < len; i++) + { + if (i in t && fun.call(thisArg, t[i], i, t)) + return true; + } + + return false; + }; +} + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map +if (!Array.prototype.map) { + + Array.prototype.map = function(callback/*, thisArg*/) { + + var T, A, k; + + if (this == null) { + throw new TypeError('this is null or not defined'); + } + + // 1. Let O be the result of calling ToObject passing the |this| + // value as the argument. + var O = Object(this); + + // 2. Let lenValue be the result of calling the Get internal + // method of O with the argument "length". + // 3. Let len be ToUint32(lenValue). + var len = O.length >>> 0; + + // 4. If IsCallable(callback) is false, throw a TypeError exception. + // See: http://es5.github.com/#x9.11 + if (typeof callback !== 'function') { + throw new TypeError(callback + ' is not a function'); + } + + // 5. If thisArg was supplied, let T be thisArg; else let T be undefined. + if (arguments.length > 1) { + T = arguments[1]; + } + + // 6. Let A be a new array created as if by the expression new Array(len) + // where Array is the standard built-in constructor with that name and + // len is the value of len. + A = new Array(len); + + // 7. Let k be 0 + k = 0; + + // 8. Repeat, while k < len + while (k < len) { + + var kValue, mappedValue; + + // a. Let Pk be ToString(k). + // This is implicit for LHS operands of the in operator + // b. Let kPresent be the result of calling the HasProperty internal + // method of O with argument Pk. + // This step can be combined with c + // c. If kPresent is true, then + if (k in O) { + + // i. Let kValue be the result of calling the Get internal + // method of O with argument Pk. + kValue = O[k]; + + // ii. Let mappedValue be the result of calling the Call internal + // method of callback with T as the this value and argument + // list containing kValue, k, and O. + mappedValue = callback.call(T, kValue, k, O); + + // iii. Call the DefineOwnProperty internal method of A with arguments + // Pk, Property Descriptor + // { Value: mappedValue, + // Writable: true, + // Enumerable: true, + // Configurable: true }, + // and false. + + // In browsers that support Object.defineProperty, use the following: + // Object.defineProperty(A, k, { + // value: mappedValue, + // writable: true, + // enumerable: true, + // configurable: true + // }); + + // For best browser support, use the following: + A[k] = mappedValue; + } + // d. Increase k by 1. + k++; + } + + // 9. return A + return A; + }; +} + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_objects/Function/bind +if (!Function.prototype.bind) (function(){ + var ArrayPrototypeSlice = Array.prototype.slice; + Function.prototype.bind = function(otherThis) { + if (typeof this !== 'function') { + // closest thing possible to the ECMAScript 5 + // internal IsCallable function + throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable'); + } + + var baseArgs= ArrayPrototypeSlice .call(arguments, 1), + baseArgsLength = baseArgs.length, + fToBind = this, + fNOP = function() {}, + fBound = function() { + baseArgs.length = baseArgsLength; // reset to default base arguments + baseArgs.push.apply(baseArgs, arguments); + return fToBind.apply( + fNOP.prototype.isPrototypeOf(this) ? this : otherThis, baseArgs + ); + }; + + if (this.prototype) { + // Function.prototype doesn't have a prototype property + fNOP.prototype = this.prototype; + } + fBound.prototype = new fNOP(); + + return fBound; + }; +})(); diff --git a/app/assets/javascripts/i18n/translations.js b/app/assets/javascripts/i18n/translations.js new file mode 100644 index 00000000..b048a6e8 --- /dev/null +++ b/app/assets/javascripts/i18n/translations.js @@ -0,0 +1,3 @@ +//= require i18n/shims +//= require i18n +//= require i18n/filtered diff --git a/gemfiles/i18n_0_6.gemfile b/gemfiles/i18n_0_6.gemfile new file mode 100644 index 00000000..118efcce --- /dev/null +++ b/gemfiles/i18n_0_6.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "i18n", "~> 0.6.0" + +gemspec path: "../" diff --git a/gemfiles/i18n_0_7.gemfile b/gemfiles/i18n_0_7.gemfile new file mode 100644 index 00000000..d1447838 --- /dev/null +++ b/gemfiles/i18n_0_7.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "i18n", "~> 0.7.0" + +gemspec path: "../" diff --git a/gemfiles/i18n_0_8.gemfile b/gemfiles/i18n_0_8.gemfile new file mode 100644 index 00000000..eb4461c2 --- /dev/null +++ b/gemfiles/i18n_0_8.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "i18n", "~> 0.8.0" + +gemspec path: "../" diff --git a/gemfiles/i18n_0_9.gemfile b/gemfiles/i18n_0_9.gemfile new file mode 100644 index 00000000..771f77bb --- /dev/null +++ b/gemfiles/i18n_0_9.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "i18n", "~> 0.9.0" + +gemspec path: "../" diff --git a/gemfiles/i18n_1_0.gemfile b/gemfiles/i18n_1_0.gemfile new file mode 100644 index 00000000..0b6bd01d --- /dev/null +++ b/gemfiles/i18n_1_0.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "i18n", "~> 1.0.0" + +gemspec path: "../" diff --git a/gemfiles/i18n_1_1.gemfile b/gemfiles/i18n_1_1.gemfile new file mode 100644 index 00000000..e63fb87e --- /dev/null +++ b/gemfiles/i18n_1_1.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "i18n", "~> 1.1.0" + +gemspec path: "../" diff --git a/gemfiles/i18n_1_2.gemfile b/gemfiles/i18n_1_2.gemfile new file mode 100644 index 00000000..836ee44e --- /dev/null +++ b/gemfiles/i18n_1_2.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "i18n", "~> 1.2.0" + +gemspec path: "../" diff --git a/gemfiles/i18n_1_3.gemfile b/gemfiles/i18n_1_3.gemfile new file mode 100644 index 00000000..a01f6d1d --- /dev/null +++ b/gemfiles/i18n_1_3.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "i18n", "~> 1.3.0" + +gemspec path: "../" diff --git a/gemfiles/i18n_1_4.gemfile b/gemfiles/i18n_1_4.gemfile new file mode 100644 index 00000000..2ee82d80 --- /dev/null +++ b/gemfiles/i18n_1_4.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "i18n", "~> 1.4.0" + +gemspec path: "../" diff --git a/gemfiles/i18n_1_5.gemfile b/gemfiles/i18n_1_5.gemfile new file mode 100644 index 00000000..f33f1985 --- /dev/null +++ b/gemfiles/i18n_1_5.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "i18n", "~> 1.5.1" + +gemspec path: "../" diff --git a/gemfiles/i18n_1_6.gemfile b/gemfiles/i18n_1_6.gemfile new file mode 100644 index 00000000..d1b3a7d1 --- /dev/null +++ b/gemfiles/i18n_1_6.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "i18n", "~> 1.6.0" + +gemspec path: "../" diff --git a/gemfiles/i18n_1_7.gemfile b/gemfiles/i18n_1_7.gemfile new file mode 100644 index 00000000..576d9e32 --- /dev/null +++ b/gemfiles/i18n_1_7.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "i18n", "~> 1.7.0" + +gemspec path: "../" diff --git a/gemfiles/i18n_1_8.gemfile b/gemfiles/i18n_1_8.gemfile new file mode 100644 index 00000000..1d5e4cf8 --- /dev/null +++ b/gemfiles/i18n_1_8.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "i18n", "~> 1.8.0" + +gemspec path: "../" diff --git a/i18n-js.gemspec b/i18n-js.gemspec index 13e31281..214ebdb8 100644 --- a/i18n-js.gemspec +++ b/i18n-js.gemspec @@ -1,27 +1,30 @@ # -*- encoding: utf-8 -*- $:.push File.expand_path("../lib", __FILE__) -require "i18n-js/version" +require "i18n/js/version" Gem::Specification.new do |s| s.name = "i18n-js" - s.version = SimplesIdeias::I18n::Version::STRING + s.version = I18n::JS::VERSION s.platform = Gem::Platform::RUBY s.authors = ["Nando Vieira"] s.email = ["fnando.vieira@gmail.com"] s.homepage = "http://rubygems.org/gems/i18n-js" s.summary = "It's a small library to provide the Rails I18n translations on the Javascript." s.description = s.summary + s.license = "MIT" 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_dependency "i18n" - s.add_development_dependency "fakeweb" - s.add_development_dependency "activesupport", ">= 3.0.0" - s.add_development_dependency "rspec", "~> 2.6" - s.add_development_dependency "spec-js", "~> 0.1.0.beta.0" - s.add_development_dependency "rake" - s.add_development_dependency "pry" + s.add_dependency "i18n", ">= 0.6.6" + + s.add_development_dependency "appraisal", "~> 2.3" + s.add_development_dependency "rspec", "~> 3.0" + s.add_development_dependency "rake", "~> 12.0" + s.add_development_dependency "gem-release", ">= 0.7" + s.add_development_dependency "coveralls", ">= 0.7" + + s.required_ruby_version = ">= 2.1.0" end diff --git a/i18njs.png b/i18njs.png new file mode 100644 index 00000000..d322733c Binary files /dev/null and b/i18njs.png differ diff --git a/lib/i18n-js.rb b/lib/i18n-js.rb index e26f08b6..255c25a7 100644 --- a/lib/i18n-js.rb +++ b/lib/i18n-js.rb @@ -1,177 +1 @@ -require "FileUtils" unless defined?(FileUtils) - -module SimplesIdeias - module I18n - extend self - - require "i18n-js/railtie" if Rails.version >= "3.0" - require "i18n-js/engine" if Rails.version >= "3.1" - require "i18n-js/middleware" - - # deep_merge by Stefan Rusterholz, see http://www.ruby-forum.com/topic/142809 - MERGER = proc { |key, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &MERGER) : v2 } - - # Under rails 3.1.1 and higher, perform a check to ensure that the - # full environment will be available during asset compilation. - # This is required to ensure I18n is loaded. - def assert_usable_configuration! - @usable_configuration ||= Rails.version >= "3.1.1" && - Rails.configuration.assets.initialize_on_precompile || - raise("Cannot precompile i18n-js translations unless environment is initialized. Please set config.assets.initialize_on_precompile to true.") - end - - def has_asset_pipeline? - Rails.configuration.respond_to?(:assets) && Rails.configuration.assets.enabled - end - - def config_file - Rails.root.join("config/i18n-js.yml") - end - - def export_dir - if has_asset_pipeline? - "app/assets/javascripts/i18n" - else - "public/javascripts" - end - end - - def javascript_file - Rails.root.join(export_dir, "i18n.js") - end - - # Export translations to JavaScript, considering settings - # from configuration file - def export! - translation_segments.each do |filename, translations| - save(translations, filename) - end - end - - def segments_per_locale(pattern,scope) - ::I18n.available_locales.each_with_object({}) do |locale,segments| - result = scoped_translations("#{locale}.#{scope}") - unless result.empty? - segment_name = ::I18n.interpolate(pattern,{:locale => locale}) - segments[segment_name] = result - end - end - end - - def segment_for_scope(scope) - if scope == "*" - translations - else - scoped_translations(scope) - end - end - - def configured_segments - config[:translations].each_with_object({}) do |options,segments| - options.reverse_merge!(:only => "*") - if options[:file] =~ ::I18n::INTERPOLATION_PATTERN - segments.merge!(segments_per_locale(options[:file],options[:only])) - else - result = segment_for_scope(options[:only]) - segments[options[:file]] = result unless result.empty? - end - end - end - - def translation_segments - if config? && config[:translations] - configured_segments - else - {"#{export_dir}/translations.js" => translations} - end - end - - # Load configuration file for partial exporting and - # custom output directory - def config - if config? - (YAML.load_file(config_file) || {}).with_indifferent_access - else - {} - end - end - - # Check if configuration file exist - def config? - File.file? config_file - end - - # Copy configuration and JavaScript library files to - # config/i18n-js.yml and public/javascripts/i18n.js. - def setup! - FileUtils.cp(File.dirname(__FILE__) + "/../vendor/assets/javascripts/i18n.js", javascript_file) unless Rails.version >= "3.1" - FileUtils.cp(File.dirname(__FILE__) + "/../config/i18n-js.yml", config_file) unless config? - end - - # Retrieve an updated JavaScript library from Github. - def update! - require "open-uri" - contents = open("https://raw.github.com/fnando/i18n-js/master/vendor/assets/javascripts/i18n.js").read - File.open(javascript_file, "w+") {|f| f << contents} - end - - # Convert translations to JSON string and save file. - def save(translations, file) - file = Rails.root.join(file) - FileUtils.mkdir_p File.dirname(file) - - File.open(file, "w+") do |f| - f << %(var I18n = I18n || {};\n) - f << %(I18n.translations = ); - f << translations.to_json - f << %(;) - end - end - - def scoped_translations(scopes) # :nodoc: - result = {} - - [scopes].flatten.each do |scope| - deep_merge! result, filter(translations, scope) - end - - result - end - - # Filter translations according to the specified scope. - def filter(translations, scopes) - scopes = scopes.split(".") if scopes.is_a?(String) - scopes = scopes.clone - scope = scopes.shift - - if scope == "*" - results = {} - translations.each do |scope, translations| - tmp = scopes.empty? ? translations : filter(translations, scopes) - results[scope.to_sym] = tmp unless tmp.nil? - end - return results - elsif translations.has_key?(scope.to_sym) - return {scope.to_sym => scopes.empty? ? translations[scope.to_sym] : filter(translations[scope.to_sym], scopes)} - end - nil - end - - # Initialize and return translations - def translations - ::I18n.backend.instance_eval do - init_translations unless initialized? - translations - end - end - - def deep_merge(target, hash) # :nodoc: - target.merge(hash, &MERGER) - end - - def deep_merge!(target, hash) # :nodoc: - target.merge!(hash, &MERGER) - end - end -end - +require "i18n/js" \ No newline at end of file diff --git a/lib/i18n-js/engine.rb b/lib/i18n-js/engine.rb deleted file mode 100644 index 8dfaaf3d..00000000 --- a/lib/i18n-js/engine.rb +++ /dev/null @@ -1,63 +0,0 @@ -module SimplesIdeias - module I18n - class Engine < ::Rails::Engine - I18N_TRANSLATIONS_ASSET = "i18n/translations" - - initializer "i18n-js.asset_dependencies", :after => "sprockets.environment", - :before => "i18n-js.initialize" do - next unless SimplesIdeias::I18n.has_asset_pipeline? - - config = I18n.config_file - cache_file = I18n::Engine.load_path_hash_cache - - Rails.application.assets.register_preprocessor "application/javascript", :"i18n-js_dependencies" do |context, data| - if context.logical_path == I18N_TRANSLATIONS_ASSET - context.depend_on(config) if I18n.config? - # also set up dependencies on every locale file - ::I18n.load_path.each {|path| context.depend_on(path)} - - # Set up a dependency on the contents of the load path - # itself. In some situations it is possible to get here - # before the path hash cache file has been written; in - # this situation, write it now. - I18n::Engine.write_hash! unless File.exists?(cache_file) - context.depend_on(cache_file) - end - - data - end - end - - # rewrite path cache hash at startup and before each request in development - config.to_prepare do - next unless SimplesIdeias::I18n.has_asset_pipeline? - SimplesIdeias::I18n::Engine.write_hash_if_changed unless Rails.env.production? - end - - def self.load_path_hash_cache - @load_path_hash_cache ||= Rails.root.join("tmp/i18n-js.cache") - end - - def self.write_hash_if_changed - load_path_hash = ::I18n.load_path.hash - - if load_path_hash != cached_load_path_hash - self.cached_load_path_hash = load_path_hash - write_hash! - end - end - - def self.write_hash! - FileUtils.mkdir_p Rails.root.join("tmp") - - File.open(load_path_hash_cache, "w+") do |f| - f.write(cached_load_path_hash) - end - end - - class << self - attr_accessor :cached_load_path_hash - end - end - end -end diff --git a/lib/i18n-js/railtie.rb b/lib/i18n-js/railtie.rb deleted file mode 100644 index 3a6b75ca..00000000 --- a/lib/i18n-js/railtie.rb +++ /dev/null @@ -1,13 +0,0 @@ -module SimplesIdeias - module I18n - class Railtie < Rails::Railtie - rake_tasks do - require "i18n-js/rake" - end - - initializer "i18n-js.initialize" do |app| - app.config.middleware.use(Middleware) if Rails.env.development? && !SimplesIdeias::I18n.has_asset_pipeline? - end - end - end -end diff --git a/lib/i18n-js/rake.rb b/lib/i18n-js/rake.rb deleted file mode 100644 index 03b5d890..00000000 --- a/lib/i18n-js/rake.rb +++ /dev/null @@ -1,16 +0,0 @@ -namespace "i18n:js" do - desc "Copy i18n.js and configuration file" - task :setup => :environment do - SimplesIdeias::I18n.setup! - end - - desc "Export the messages files" - task :export => :environment do - SimplesIdeias::I18n.export! - end - - desc "Update the JavaScript library" - task :update => :environment do - SimplesIdeias::I18n.update! - end -end diff --git a/lib/i18n-js/version.rb b/lib/i18n-js/version.rb deleted file mode 100644 index ac0b095b..00000000 --- a/lib/i18n-js/version.rb +++ /dev/null @@ -1,10 +0,0 @@ -module SimplesIdeias - module I18n - module Version - MAJOR = 2 - MINOR = 1 - PATCH = 2 - STRING = "#{MAJOR}.#{MINOR}.#{PATCH}" - end - end -end diff --git a/lib/i18n/js.rb b/lib/i18n/js.rb new file mode 100644 index 00000000..f398670f --- /dev/null +++ b/lib/i18n/js.rb @@ -0,0 +1,259 @@ +require "yaml" +require "fileutils" +require "i18n" + +require "i18n/js/utils" +require "i18n/js/private/hash_with_symbol_keys" + +module I18n + module JS + require "i18n/js/dependencies" + require "i18n/js/fallback_locales" + require "i18n/js/segment" + if JS::Dependencies.rails? + require "i18n/js/middleware" + require "i18n/js/engine" + end + + DEFAULT_CONFIG_PATH = "config/i18n-js.yml" + DEFAULT_EXPORT_DIR_PATH = "public/javascripts" + + # The configuration file. This defaults to the `config/i18n-js.yml` file. + # + def self.config_file_path + @config_file_path ||= DEFAULT_CONFIG_PATH + end + + def self.config_file_path=(new_path) + @config_file_path = new_path + end + + # Allow using a different backend than the one globally configured + def self.backend + @backend ||= I18n.backend + end + + def self.backend=(alternative_backend) + @backend = alternative_backend + end + + # Export translations to JavaScript, considering settings + # from configuration file + def self.export + export_i18n_js + + translation_segments.each(&:save!) + end + + def self.segment_for_scope(scope, exceptions) + if scope == "*" + exclude(translations, exceptions) + else + scoped_translations(scope, exceptions) + end + end + + def self.configured_segments + config[:translations].inject([]) do |segments, options_hash| + options_hash_with_symbol_keys = Private::HashWithSymbolKeys.new(options_hash) + file = options_hash_with_symbol_keys[:file] + only = options_hash_with_symbol_keys[:only] || '*' + exceptions = [options_hash_with_symbol_keys[:except] || []].flatten + + result = segment_for_scope(only, exceptions) + + merge_with_fallbacks!(result) if fallbacks + + unless result.empty? + segments << Segment.new( + file, + result, + extract_segment_options(options_hash_with_symbol_keys), + ) + end + + segments + end + end + + # deep_merge! given result with result for fallback locale + def self.merge_with_fallbacks!(result) + I18n.available_locales.each do |locale| + fallback_locales = FallbackLocales.new(fallbacks, locale) + fallback_locales.each do |fallback_locale| + # `result[fallback_locale]` could be missing + result[locale] = Utils.deep_merge(result[fallback_locale] || {}, result[locale] || {}) + end + end + end + + def self.filtered_translations + translations = {}.tap do |result| + translation_segments.each do |segment| + Utils.deep_merge!(result, segment.translations) + end + end + return Utils.deep_key_sort(translations) if I18n::JS.sort_translation_keys? + translations + end + + def self.translation_segments + if config_file_exists? && config[:translations] + configured_segments + else + [Segment.new("#{DEFAULT_EXPORT_DIR_PATH}/translations.js", translations)] + end + end + + # Load configuration file for partial exporting and + # custom output directory + def self.config + if config_file_exists? + erb_result_from_yaml_file = ERB.new(File.read(config_file_path)).result + Private::HashWithSymbolKeys.new( + (::YAML.load(erb_result_from_yaml_file) || {}) + ) + else + Private::HashWithSymbolKeys.new({}) + end.freeze + end + + # @api private + # Check if configuration file exist + def self.config_file_exists? + File.file? config_file_path + end + + def self.scoped_translations(scopes, exceptions = []) # :nodoc: + result = {} + + [scopes].flatten.each do |scope| + translations_without_exceptions = exclude(translations, exceptions) + filtered_translations = filter(translations_without_exceptions, scope) || {} + + Utils.deep_merge!(result, filtered_translations) + end + + result + end + + # Exclude keys from translations listed in the `except:` section in the config file + def self.exclude(translations, exceptions) + return translations if exceptions.empty? + + exceptions.inject(translations) do |memo, exception| + exception_scopes = exception.to_s.split(".") + Utils.deep_reject(memo) do |key, value, scopes| + Utils.scopes_match?(scopes, exception_scopes) + end + end + end + + # Filter translations according to the specified scope. + def self.filter(translations, scopes) + scopes = scopes.split(".") if scopes.is_a?(String) + scopes = scopes.clone + scope = scopes.shift + + if scope == "*" + results = {} + translations.each do |scope, translations| + tmp = scopes.empty? ? translations : filter(translations, scopes) + results[scope.to_sym] = tmp unless tmp.nil? + end + return results + elsif translations.respond_to?(:key?) && translations.key?(scope.to_sym) + return {scope.to_sym => scopes.empty? ? translations[scope.to_sym] : filter(translations[scope.to_sym], scopes)} + end + nil + end + + # Initialize and return translations + def self.translations + self.backend.instance_eval do + init_translations unless initialized? + # When activesupport is absent, + # the core extension (`#slice`) from `i18n` gem will be used instead + # And it's causing errors (at least in test) + # + # So the input is wrapped by our class for better `#slice` + Private::HashWithSymbolKeys.new(translations). + slice(*::I18n.available_locales). + to_h + end + end + + def self.use_fallbacks? + fallbacks != false + end + + def self.json_only + config.fetch(:json_only) do + # default value + false + end + end + + def self.fallbacks + config.fetch(:fallbacks) do + # default value + true + end + end + + def self.js_extend + config.fetch(:js_extend) do + # default value + true + end + end + + def self.sort_translation_keys? + @sort_translation_keys ||= (config[:sort_translation_keys]) if config.key?(:sort_translation_keys) + @sort_translation_keys = true if @sort_translation_keys.nil? + @sort_translation_keys + end + + def self.sort_translation_keys=(value) + @sort_translation_keys = !!value + end + + def self.extract_segment_options(options) + segment_options = Private::HashWithSymbolKeys.new({ + js_extend: js_extend, + sort_translation_keys: sort_translation_keys?, + json_only: json_only + }).freeze + segment_options.merge(options.slice(*Segment::OPTIONS)) + end + + ### Export i18n.js + begin + + # Copy i18n.js + def self.export_i18n_js + return unless export_i18n_js_dir_path.is_a? String + + FileUtils.mkdir_p(export_i18n_js_dir_path) + + i18n_js_path = File.expand_path('../../../app/assets/javascripts/i18n.js', __FILE__) + destination_path = File.expand_path("i18n.js", export_i18n_js_dir_path) + return if File.exist?(destination_path) && FileUtils.identical?(i18n_js_path, destination_path) + + FileUtils.cp(i18n_js_path, export_i18n_js_dir_path) + end + + def self.export_i18n_js_dir_path + @export_i18n_js_dir_path ||= (config[:export_i18n_js] || :none) if config.key?(:export_i18n_js) + @export_i18n_js_dir_path ||= DEFAULT_EXPORT_DIR_PATH + @export_i18n_js_dir_path + end + + # Setting this to nil would disable i18n.js exporting + def self.export_i18n_js_dir_path=(new_path) + new_path = :none unless new_path.is_a? String + @export_i18n_js_dir_path = new_path + end + end + end +end diff --git a/lib/i18n/js/dependencies.rb b/lib/i18n/js/dependencies.rb new file mode 100644 index 00000000..009a6c62 --- /dev/null +++ b/lib/i18n/js/dependencies.rb @@ -0,0 +1,63 @@ +module I18n + module JS + # When using `safe_gem_check` to check for a pre-release version of gem, + # we need to specify pre-release version suffix in version constraint + module Dependencies + class << self + def rails? + defined?(Rails) && Rails.respond_to?(:version) + end + + def sprockets_rails_v2_plus? + safe_gem_check("sprockets-rails", ">= 2") + end + + # This cannot be called at class definition time + # Since not all libraries are loaded + # + # Call this in an initializer + def using_asset_pipeline? + assets_pipeline_available = + (rails3? || rails4? || rails5? || rails6?) && + Rails.respond_to?(:application) && + Rails.application.config.respond_to?(:assets) + rails3_assets_enabled = + rails3? && + assets_pipeline_available && + Rails.application.config.assets.enabled != false + + assets_pipeline_available && (rails4? || rails5? || rails6? || rails3_assets_enabled) + end + + private + + def rails3? + rails? && Rails.version.to_i == 3 + end + + def rails4? + rails? && Rails.version.to_i == 4 + end + + def rails5? + rails? && Rails.version.to_i == 5 + end + + def rails6? + rails? && Rails.version.to_i == 6 + end + + def safe_gem_check(*args) + if Gem::Specification.respond_to?(:find_by_name) + Gem::Specification.find_by_name(*args) + elsif Gem.respond_to?(:available?) + Gem.available?(*args) + end + rescue Gem::LoadError + false + end + + end + end + end +end diff --git a/lib/i18n/js/engine.rb b/lib/i18n/js/engine.rb new file mode 100644 index 00000000..4c0e201d --- /dev/null +++ b/lib/i18n/js/engine.rb @@ -0,0 +1,87 @@ +require "i18n/js" + +module I18n + module JS + # @api private + # The class cannot be private + class SprocketsExtension + # Actual definition is placed below + end + + class Engine < ::Rails::Engine + # See https://github.com/rails/sprockets/blob/master/guides/extending_sprockets.md#supporting-all-versions-of-sprockets-in-processors + # for reference of supporting multiple versions + + # `sprockets.environment` was used for 1.x of `sprockets-rails` + # https://github.com/rails/sprockets-rails/issues/227 + # + # References for current values: + # + # Here is where sprockets are attached with Rails. There is no 'sprockets.environment' mentioned. + # https://github.com/rails/sprockets-rails/blob/master/lib/sprockets/railtie.rb + # + # Finisher hook is the place which should be used as border. + # http://guides.rubyonrails.org/configuring.html#initializers + # + # For detail see Pull Request: + # https://github.com/fnando/i18n-js/pull/371 + initializer "i18n-js.register_preprocessor", after: :engines_blank_point, before: :finisher_hook do + # This must be called inside initializer block + # For details see comments for `using_asset_pipeline?` + next unless JS::Dependencies.using_asset_pipeline? + + # From README of 2.x & 3.x of `sprockets-rails` + # It seems the `configure` block is preferred way to call `register_preprocessor` + # Not sure if this will break older versions of rails + # + # https://github.com/rails/sprockets-rails/blob/v2.3.3/README.md + # https://github.com/rails/sprockets-rails/blob/v3.0.0/README.md + if JS::Dependencies.sprockets_rails_v2_plus? + Rails.application.config.assets.configure do |config| + config.register_preprocessor("application/javascript", ::I18n::JS::SprocketsExtension) + end + elsif Rails.application.assets.respond_to?(:register_preprocessor) + Rails.application.assets.register_preprocessor("application/javascript", ::I18n::JS::SprocketsExtension) + end + end + end + + # @api private + class SprocketsExtension + def initialize(filename, &block) + @filename = filename + @source = block.call + end + + def render(context, empty_hash_wtf) + self.class.run(@filename, @source, context) + end + + def self.run(filename, source, context) + if context.logical_path == "i18n/filtered" + ::I18n.load_path.each { |path| context.depend_on(File.expand_path(path)) } + + # Absolute path is required or + # Sprockets assumes it's a logical path + # + # Calling `depend on` with an absent file + # will invoke `resolve` and will throw an error in the end + if I18n::JS.config_file_exists? + context.depend_on(File.expand_path(I18n::JS.config_file_path)) + end + end + + source + end + + def self.call(input) + filename = input[:filename] + source = input[:data] + context = input[:environment].context_class.new(input) + + result = run(filename, source, context) + context.metadata.merge(data: result) + end + end + end +end diff --git a/lib/i18n/js/fallback_locales.rb b/lib/i18n/js/fallback_locales.rb new file mode 100644 index 00000000..79cf07c3 --- /dev/null +++ b/lib/i18n/js/fallback_locales.rb @@ -0,0 +1,70 @@ +module I18n + module JS + class FallbackLocales + attr_reader :fallbacks, :locale + + def initialize(fallbacks, locale) + @fallbacks = fallbacks + @locale = locale + end + + def each + locales.each { |locale| yield(locale) } + end + + # @return [Array] + # An Array of locales to use as fallbacks for given locale. + def locales + locales = case fallbacks + when true + default_fallbacks + when :default_locale + [::I18n.default_locale] + when Symbol, String + [fallbacks.to_sym] + when Array + ensure_valid_fallbacks_as_array! + fallbacks + when Hash + Array(fallbacks[locale] || default_fallbacks) + else + fail ArgumentError, "fallbacks must be: true, :default_locale an Array or a Hash - given: #{fallbacks}" + end + + locales.map! { |locale| locale.to_sym } + locales + end + + private + + # @return [Array] An Array of locales. + def default_fallbacks + if using_i18n_fallbacks_module? + I18n.fallbacks[locale] + else + [::I18n.default_locale] + end + end + + # @return + # true if we can safely use I18n.fallbacks, false otherwise. + # + # @note + # We should implement this as `I18n.respond_to?(:fallbacks)`, but + # once I18n::Backend::Fallbacks is included, I18n will _always_ + # respond to :fallbacks. Even if we switch the backend to one + # without fallbacks! + # + # Maybe this should be fixed within I18n. + def using_i18n_fallbacks_module? + I18n::JS.backend.class.included_modules.include?(I18n::Backend::Fallbacks) + end + + def ensure_valid_fallbacks_as_array! + return if fallbacks.all? { |e| e.is_a?(String) || e.is_a?(Symbol) } + + fail ArgumentError, "If fallbacks is passed as Array, it must ony include Strings or Symbols. Given: #{fallbacks}" + end + end + end +end diff --git a/lib/i18n/js/formatters/base.rb b/lib/i18n/js/formatters/base.rb new file mode 100644 index 00000000..c76c2976 --- /dev/null +++ b/lib/i18n/js/formatters/base.rb @@ -0,0 +1,25 @@ +module I18n + module JS + module Formatters + class Base + def initialize(js_extend: false, namespace: nil, pretty_print: false, prefix: nil, suffix: nil) + @js_extend = js_extend + @namespace = namespace + @pretty_print = pretty_print + @prefix = prefix + @suffix = suffix + end + + protected + + def format_json(translations) + if @pretty_print + ::JSON.pretty_generate(translations) + else + translations.to_json + end + end + end + end + end +end diff --git a/lib/i18n/js/formatters/js.rb b/lib/i18n/js/formatters/js.rb new file mode 100644 index 00000000..cd2e28c9 --- /dev/null +++ b/lib/i18n/js/formatters/js.rb @@ -0,0 +1,32 @@ +require "i18n/js/formatters/base" + +module I18n + module JS + module Formatters + class JS < Base + def format(translations) + contents = header + translations.each do |locale, translations_for_locale| + contents << line(locale, format_json(translations_for_locale)) + end + contents << (@suffix || '') + end + + protected + + def header + text = @prefix || '' + text + %(#{@namespace}.translations || (#{@namespace}.translations = {});\n) + end + + def line(locale, translations) + if @js_extend + %(#{@namespace}.translations["#{locale}"] = I18n.extend((#{@namespace}.translations["#{locale}"] || {}), #{translations});\n) + else + %(#{@namespace}.translations["#{locale}"] = #{translations};\n) + end + end + end + end + end +end diff --git a/lib/i18n/js/formatters/json.rb b/lib/i18n/js/formatters/json.rb new file mode 100644 index 00000000..a0eb91fd --- /dev/null +++ b/lib/i18n/js/formatters/json.rb @@ -0,0 +1,13 @@ +require "i18n/js/formatters/base" + +module I18n + module JS + module Formatters + class JSON < Base + def format(translations) + format_json(translations) + end + end + end + end +end diff --git a/lib/i18n-js/middleware.rb b/lib/i18n/js/middleware.rb similarity index 56% rename from lib/i18n-js/middleware.rb rename to lib/i18n/js/middleware.rb index ff019247..6471c3e2 100644 --- a/lib/i18n-js/middleware.rb +++ b/lib/i18n/js/middleware.rb @@ -1,8 +1,11 @@ -module SimplesIdeias - module I18n +require "fileutils" + +module I18n + module JS class Middleware def initialize(app) @app = app + clear_cache end def call(env) @@ -13,7 +16,11 @@ def call(env) private def cache_path - @cache_path ||= Rails.root.join("tmp/cache/i18n-js.yml") + @cache_path ||= cache_dir.join("i18n-js.yml") + end + + def cache_dir + @cache_dir ||= Rails.root.join("tmp/cache") end def cache @@ -26,6 +33,24 @@ def cache end end + def clear_cache + # `File.delete` will raise error when "multiple worker" + # Are running at the same time, like in a parallel test + # + # `FileUtils.rm_f` is tested manually + # + # See https://github.com/fnando/i18n-js/issues/436 + FileUtils.rm_f(cache_path) if File.exist?(cache_path) + end + + def save_cache(new_cache) + # path could be a symbolic link + FileUtils.mkdir_p(cache_dir) unless File.exists?(cache_dir) + File.open(cache_path, "w+") do |file| + file << new_cache.to_yaml + end + end + # Check if translations should be regenerated. # ONLY REGENERATE when these conditions are met: # @@ -46,13 +71,11 @@ def verify_locale_files! new_cache[path] = changed_at end - unless valid_cache.all? - File.open(cache_path, "w+") do |file| - file << new_cache.to_yaml - end + return if valid_cache.all? - SimplesIdeias::I18n.export! - end + save_cache(new_cache) + + ::I18n::JS.export end end end diff --git a/lib/i18n/js/private/hash_with_symbol_keys.rb b/lib/i18n/js/private/hash_with_symbol_keys.rb new file mode 100644 index 00000000..e289acef --- /dev/null +++ b/lib/i18n/js/private/hash_with_symbol_keys.rb @@ -0,0 +1,36 @@ +module I18n + module JS + # @api private + module Private + # Hash with string keys converted to symbol keys + # Used for handling values read on YAML + # + # @api private + class HashWithSymbolKeys < ::Hash + # An instance can only be created by passing in another hash + def initialize(hash) + raise TypeError unless hash.is_a?(::Hash) + + hash.each_key do |key| + # Objects like `Integer` does not have `to_sym` + new_key = key.respond_to?(:to_sym) ? key.to_sym : key + self[new_key] = hash[key] + end + + self.default = hash.default if hash.default + self.default_proc = hash.default_proc if hash.default_proc + + freeze + end + + # From AS Core extension + def slice(*keys) + hash = keys.each_with_object(Hash.new) do |k, hash| + hash[k] = self[k] if has_key?(k) + end + self.class.new(hash) + end + end + end + end +end diff --git a/lib/i18n/js/segment.rb b/lib/i18n/js/segment.rb new file mode 100644 index 00000000..64544b23 --- /dev/null +++ b/lib/i18n/js/segment.rb @@ -0,0 +1,80 @@ +require "i18n/js/private/hash_with_symbol_keys" +require "i18n/js/formatters/js" +require "i18n/js/formatters/json" + +module I18n + module JS + + # Class which enscapulates a translations hash and outputs a single JSON translation file + class Segment + OPTIONS = [:namespace, :pretty_print, :js_extend, :prefix, :suffix, :sort_translation_keys, :json_only].freeze + LOCALE_INTERPOLATOR = /%\{locale\}/ + + attr_reader *([:file, :translations] | OPTIONS) + + def initialize(file, translations, options = {}) + @file = file + # `#slice` will be used + # But when activesupport is absent, + # the core extension from `i18n` gem will be used instead + # And it's causing errors (at least in test) + # + # So the input is wrapped by our class for better `#slice` + @translations = Private::HashWithSymbolKeys.new(translations) + @namespace = options[:namespace] || 'I18n' + @pretty_print = !!options[:pretty_print] + @js_extend = options.key?(:js_extend) ? !!options[:js_extend] : true + @prefix = options.key?(:prefix) ? options[:prefix] : nil + @suffix = options.key?(:suffix) ? options[:suffix] : nil + @sort_translation_keys = options.key?(:sort_translation_keys) ? !!options[:sort_translation_keys] : true + @json_only = options.key?(:json_only) ? !!options[:json_only] : false + end + + # Saves JSON file containing translations + def save! + if @file =~ LOCALE_INTERPOLATOR + I18n.available_locales.each do |locale| + write_file(file_for_locale(locale), @translations.slice(locale)) + end + else + write_file + end + end + + protected + + def file_for_locale(locale) + @file.gsub(LOCALE_INTERPOLATOR, locale.to_s) + end + + def write_file(_file = @file, _translations = @translations) + FileUtils.mkdir_p File.dirname(_file) + _translations = Utils.deep_key_sort(_translations) if @sort_translation_keys + contents = formatter.format(_translations) + + return if File.exist?(_file) && File.read(_file) == contents + + File.open(_file, "w+") do |f| + f << contents + end + end + + def formatter + if @json_only + Formatters::JSON.new(**formatter_options) + else + Formatters::JS.new(**formatter_options) + end + end + + def formatter_options + { js_extend: @js_extend, + namespace: @namespace, + pretty_print: @pretty_print, + prefix: @prefix, + suffix: @suffix + } + end + end + end +end diff --git a/lib/i18n/js/utils.rb b/lib/i18n/js/utils.rb new file mode 100644 index 00000000..1f7dfb9f --- /dev/null +++ b/lib/i18n/js/utils.rb @@ -0,0 +1,78 @@ +module I18n + module JS + module Utils + PLURAL_KEYS = %i[zero one two few many other].freeze + + # Based on deep_merge by Stefan Rusterholz, see . + # This method is used to handle I18n fallbacks. Given two equivalent path nodes in two locale trees: + # 1. If the node in the current locale appears to be an I18n pluralization (:one, :other, etc.), + # use the node, but merge in any missing/non-nil keys from the fallback (default) locale. + # 2. Else if both nodes are Hashes, combine (merge) the key-value pairs of the two nodes into one, + # prioritizing the current locale. + # 3. Else if either node is nil, use the other node. + PLURAL_MERGER = proc do |_key, v1, v2| + v1 || v2 + end + MERGER = proc do |_key, v1, v2| + if Hash === v1 && Hash === v2 + if (v2.keys - PLURAL_KEYS).empty? + slice(v2.merge(v1, &PLURAL_MERGER), v2.keys) + else + v1.merge(v2, &MERGER) + end + else + v2 || v1 + end + end + + HASH_NIL_VALUE_CLEANER_PROC = proc do |k, v| + v.kind_of?(Hash) ? (v.delete_if(&HASH_NIL_VALUE_CLEANER_PROC); false) : v.nil? + end + + def self.slice(hash, keys) + if hash.respond_to?(:slice) # ruby 2.5 onwards + hash.slice(*keys) + else + hash.select {|key, _| keys.include?(key)} + end + end + + def self.strip_keys_with_nil_values(hash) + hash.dup.delete_if(&HASH_NIL_VALUE_CLEANER_PROC) + end + + def self.deep_merge(target_hash, hash) # :nodoc: + target_hash.merge(hash, &MERGER) + end + + def self.deep_merge!(target_hash, hash) # :nodoc: + target_hash.merge!(hash, &MERGER) + end + + def self.deep_reject(hash, scopes = [], &block) + hash.each_with_object({}) do |(k, v), memo| + unless block.call(k, v, scopes + [k.to_s]) + memo[k] = v.kind_of?(Hash) ? deep_reject(v, scopes + [k.to_s], &block) : v + end + end + end + + def self.scopes_match?(scopes1, scopes2) + if scopes1.length == scopes2.length + [scopes1, scopes2].transpose.all? do |scope1, scope2| + scope1.to_s == '*' || scope2.to_s == '*' || scope1.to_s == scope2.to_s + end + end + end + + def self.deep_key_sort(hash) + # Avoid things like `true` or `1` from YAML which causes error + hash.keys.sort {|a, b| a.to_s <=> b.to_s}. + each_with_object({}) do |key, seed| + value = hash[key] + seed[key] = value.is_a?(Hash) ? deep_key_sort(value) : value + end + end + end + end +end diff --git a/lib/i18n/js/version.rb b/lib/i18n/js/version.rb new file mode 100644 index 00000000..35e183a6 --- /dev/null +++ b/lib/i18n/js/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module I18n + module JS + VERSION = "3.8.0" + end +end diff --git a/lib/rails/generators/i18n/js/config/config_generator.rb b/lib/rails/generators/i18n/js/config/config_generator.rb new file mode 100644 index 00000000..389f0afa --- /dev/null +++ b/lib/rails/generators/i18n/js/config/config_generator.rb @@ -0,0 +1,19 @@ +module I18n + module Js + class ConfigGenerator < Rails::Generators::Base + # Copied files come from templates folder + source_root File.expand_path('../templates', __FILE__) + + # Generator desc + desc <<-DESC + Creates a default i18n-js.yml configuration file in your app's config + folder. This file allows you to customize i18n:js:export rake task + outputted files. + DESC + + def copy_initializer_file + copy_file "i18n-js.yml", "config/i18n-js.yml" + end + end + end +end \ No newline at end of file diff --git a/config/i18n-js.yml b/lib/rails/generators/i18n/js/config/templates/i18n-js.yml similarity index 66% rename from config/i18n-js.yml rename to lib/rails/generators/i18n/js/config/templates/i18n-js.yml index a5ed6d89..205c42a3 100644 --- a/config/i18n-js.yml +++ b/lib/rails/generators/i18n/js/config/templates/i18n-js.yml @@ -1,4 +1,5 @@ # Split context in several files. +# # By default only one file with all translations is exported and # no configuration is required. Your settings for asset pipeline # are automatically recognized. @@ -7,16 +8,20 @@ # locale contexts that will be exported, just use this file to do # so. # +# For more informations about the export options with this file, please +# refer to the README +# +# # If you're going to use the Rails 3.1 asset pipeline, change # the following configuration to something like this: # -# translations: -# - file: "app/assets/javascripts/i18n/translations.js" +# translations: +# - file: "app/assets/javascripts/i18n/translations.js" # # If you're running an old version, you can use something # like this: # -# translations: -# - file: "public/javascripts/translations.js" -# only: "*" -# \ No newline at end of file +# translations: +# - file: "app/assets/javascripts/i18n/translations.js" +# only: "*" +# diff --git a/lib/tasks/export.rake b/lib/tasks/export.rake new file mode 100644 index 00000000..6402380b --- /dev/null +++ b/lib/tasks/export.rake @@ -0,0 +1,8 @@ +namespace :i18n do + namespace :js do + desc "Export translations to JS file(s)" + task :export => :environment do + I18n::JS.export + end + end +end diff --git a/package.json b/package.json new file mode 100644 index 00000000..d5cdf6f8 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "i18n-js", + "version": "3.8.0", + "description": "A javascript library similar to Ruby on Rails i18n gem", + "author": "Nando Vieira", + "license": "MIT", + "keywords": [ + "i18n" + ], + "devDependencies": { + "jasmine-node": "^1.14.5" + }, + "main": "app/assets/javascripts/i18n.js", + "scripts": { + "test": "./node_modules/.bin/jasmine-node spec/js" + }, + "homepage": "https://github.com/fnando/i18n-js", + "bugs": { + "url": "https://github.com/fnando/i18n-js/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/fnando/i18n-js.git" + } +} diff --git a/spec/fixtures/custom_path.yml b/spec/fixtures/custom_path.yml new file mode 100755 index 00000000..0ba16862 --- /dev/null +++ b/spec/fixtures/custom_path.yml @@ -0,0 +1,5 @@ +fallbacks: false + +translations: + - file: "tmp/i18n-js/all.js" + only: "*" diff --git a/spec/fixtures/default.yml b/spec/fixtures/default.yml new file mode 100755 index 00000000..fdaeb81b --- /dev/null +++ b/spec/fixtures/default.yml @@ -0,0 +1,5 @@ +fallbacks: false + +translations: + - file: "tmp/i18n-js/translations.js" + only: "*" diff --git a/spec/fixtures/erb.yml b/spec/fixtures/erb.yml new file mode 100644 index 00000000..3917c9e5 --- /dev/null +++ b/spec/fixtures/erb.yml @@ -0,0 +1,5 @@ +fallbacks: false + +translations: + - file: "tmp/i18n-js/translations.js" + only: '<%= "*." + "date." + "formats" %>' diff --git a/spec/fixtures/except_condition.yml b/spec/fixtures/except_condition.yml new file mode 100644 index 00000000..7388c560 --- /dev/null +++ b/spec/fixtures/except_condition.yml @@ -0,0 +1,7 @@ +fallbacks: false + +translations: + - file: "tmp/i18n-js/trimmed.js" + except: + - "active_admin" + - "mailers" diff --git a/spec/fixtures/js_export_dir_custom.yml b/spec/fixtures/js_export_dir_custom.yml new file mode 100644 index 00000000..5bcb49c4 --- /dev/null +++ b/spec/fixtures/js_export_dir_custom.yml @@ -0,0 +1,7 @@ +fallbacks: false + +export_i18n_js: 'tmp/i18n-js/foo' + +translations: + - file: "tmp/i18n-js/%{locale}.js" + only: '*' diff --git a/spec/fixtures/js_export_dir_none.yml b/spec/fixtures/js_export_dir_none.yml new file mode 100644 index 00000000..1936624c --- /dev/null +++ b/spec/fixtures/js_export_dir_none.yml @@ -0,0 +1,6 @@ +fallbacks: false +export_i18n_js: false + +translations: + - file: "tmp/i18n-js/%{locale}.js" + only: '*' diff --git a/spec/fixtures/js_extend_parent.yml b/spec/fixtures/js_extend_parent.yml new file mode 100644 index 00000000..2adc95fd --- /dev/null +++ b/spec/fixtures/js_extend_parent.yml @@ -0,0 +1,6 @@ +fallbacks: false +js_extend: false + +translations: + - file: "tmp/i18n-js/js_extend_parent.js" + only: "*.date.formats" diff --git a/spec/fixtures/js_extend_segment.yml b/spec/fixtures/js_extend_segment.yml new file mode 100644 index 00000000..3a99b316 --- /dev/null +++ b/spec/fixtures/js_extend_segment.yml @@ -0,0 +1,6 @@ +fallbacks: false + +translations: + - file: "tmp/i18n-js/js_extend_segment.js" + js_extend: false + only: "*.date.formats" diff --git a/spec/fixtures/js_file_per_locale.yml b/spec/fixtures/js_file_per_locale.yml new file mode 100755 index 00000000..60a30abf --- /dev/null +++ b/spec/fixtures/js_file_per_locale.yml @@ -0,0 +1,7 @@ +fallbacks: false + +translations: + - file: "tmp/i18n-js/%{locale}.js" + only: + - '*.date.*' + - '*.admin.*' diff --git a/spec/fixtures/js_file_per_locale_with_fallbacks_as_default_locale_symbol.yml b/spec/fixtures/js_file_per_locale_with_fallbacks_as_default_locale_symbol.yml new file mode 100644 index 00000000..6baa3df6 --- /dev/null +++ b/spec/fixtures/js_file_per_locale_with_fallbacks_as_default_locale_symbol.yml @@ -0,0 +1,4 @@ +fallbacks: :default_locale + +translations: +- file: "tmp/i18n-js/%{locale}.js" diff --git a/spec/fixtures/js_file_per_locale_with_fallbacks_as_hash.yml b/spec/fixtures/js_file_per_locale_with_fallbacks_as_hash.yml new file mode 100644 index 00000000..7e1bd583 --- /dev/null +++ b/spec/fixtures/js_file_per_locale_with_fallbacks_as_hash.yml @@ -0,0 +1,6 @@ +fallbacks: + fr: ["de", "en"] + de: "en" + +translations: + - file: "tmp/i18n-js/%{locale}.js" diff --git a/spec/fixtures/js_file_per_locale_with_fallbacks_as_locale.yml b/spec/fixtures/js_file_per_locale_with_fallbacks_as_locale.yml new file mode 100644 index 00000000..1886c419 --- /dev/null +++ b/spec/fixtures/js_file_per_locale_with_fallbacks_as_locale.yml @@ -0,0 +1,4 @@ +fallbacks: :de + +translations: +- file: "tmp/i18n-js/%{locale}.js" diff --git a/spec/fixtures/js_file_per_locale_with_fallbacks_as_locale_without_fallback_translations.yml b/spec/fixtures/js_file_per_locale_with_fallbacks_as_locale_without_fallback_translations.yml new file mode 100644 index 00000000..622ce2be --- /dev/null +++ b/spec/fixtures/js_file_per_locale_with_fallbacks_as_locale_without_fallback_translations.yml @@ -0,0 +1,4 @@ +fallbacks: :pirate + +translations: +- file: "tmp/i18n-js/%{locale}.js" diff --git a/spec/fixtures/js_file_per_locale_with_fallbacks_enabled.yml b/spec/fixtures/js_file_per_locale_with_fallbacks_enabled.yml new file mode 100644 index 00000000..0f87ffc5 --- /dev/null +++ b/spec/fixtures/js_file_per_locale_with_fallbacks_enabled.yml @@ -0,0 +1,4 @@ +fallbacks: true + +translations: +- file: "tmp/i18n-js/%{locale}.js" diff --git a/spec/fixtures/js_file_per_locale_without_fallbacks.yml b/spec/fixtures/js_file_per_locale_without_fallbacks.yml new file mode 100644 index 00000000..7fd11d55 --- /dev/null +++ b/spec/fixtures/js_file_per_locale_without_fallbacks.yml @@ -0,0 +1,4 @@ +fallbacks: false + +translations: +- file: "tmp/i18n-js/%{locale}.js" diff --git a/spec/fixtures/js_file_with_namespace_prefix_and_pretty_print.yml b/spec/fixtures/js_file_with_namespace_prefix_and_pretty_print.yml new file mode 100644 index 00000000..871fa002 --- /dev/null +++ b/spec/fixtures/js_file_with_namespace_prefix_and_pretty_print.yml @@ -0,0 +1,9 @@ +fallbacks: false + +translations: + - file: "tmp/i18n-js/%{locale}.js" + only: '*' + namespace: "Foo" + pretty_print: true + prefix: "import random from 'random-library';\n" + suffix: "//test" diff --git a/spec/fixtures/js_sort_translation_keys_false.yml b/spec/fixtures/js_sort_translation_keys_false.yml new file mode 100644 index 00000000..f47a9489 --- /dev/null +++ b/spec/fixtures/js_sort_translation_keys_false.yml @@ -0,0 +1,6 @@ + +sort_translation_keys: false + +translations: + - file: "tmp/i18n-js/%{locale}.js" + only: '*' diff --git a/spec/fixtures/js_sort_translation_keys_true.yml b/spec/fixtures/js_sort_translation_keys_true.yml new file mode 100644 index 00000000..ada09b08 --- /dev/null +++ b/spec/fixtures/js_sort_translation_keys_true.yml @@ -0,0 +1,6 @@ + +sort_translation_keys: true + +translations: + - file: "tmp/i18n-js/%{locale}.js" + only: '*' diff --git a/spec/fixtures/json_only.yml b/spec/fixtures/json_only.yml new file mode 100644 index 00000000..7327136f --- /dev/null +++ b/spec/fixtures/json_only.yml @@ -0,0 +1,18 @@ +fallbacks: false +json_only: true + +translations: + - file: "tmp/i18n-js/json_only.%{locale}.js" + only: + - "*.date.formats.*" + - "*.number.currency.*" + + - file: "tmp/i18n-js/json_only_multi.js" + only: + - "*.date.formats.*" + - "*.number.currency.*" + + - file: "tmp/i18n-js/json_only_multi_pretty.js" + only: + - "*.date.formats.*" + - "*.number.currency.*" diff --git a/spec/resources/locales.yml b/spec/fixtures/locales.yml old mode 100644 new mode 100755 similarity index 67% rename from spec/resources/locales.yml rename to spec/fixtures/locales.yml index eac57647..1ac14f9b --- a/spec/resources/locales.yml +++ b/spec/fixtures/locales.yml @@ -1,5 +1,9 @@ en: number: + human: + decimal_units: + units: + million: Million format: separator: "." delimiter: "," @@ -34,6 +38,39 @@ en: note: "more details" edit: title: "Edit" + foo: "Foo" + fallback_test: "Success" + null_test: "fallback for null" + merge_plurals: + one: Apple + other: Apples + merge_plurals_with_no_overrides: + zero: "No Apple" + one: Apple + other: Apples + merge_plurals_with_partial_overrides: + one: Cat + other: Cats + +es: + number: + human: + decimal_units: + units: + million: + one: millón + other: millones + +ru: + merge_plurals_with_no_overrides: + one: кот + few: кошек + many: кошка + other: кошек + +de: + fallback_test: "Erfolg" + null_test: ~ fr: date: @@ -73,4 +110,24 @@ fr: title: "Visualiser" note: "plus de détails" edit: - title: "Editer" \ No newline at end of file + title: "Editer" + merge_plurals: + zero: Pomme + one: Pomme + other: Pommes + +en-US: + merge_plurals_with_no_overrides: + zero: + one: + other: + merge_plurals_with_partial_overrides: + one: Cat + few: + many: + other: + +ja: + admin: + show: + title: "Ignore me" diff --git a/spec/fixtures/merge_plurals.yml b/spec/fixtures/merge_plurals.yml new file mode 100644 index 00000000..12acc67d --- /dev/null +++ b/spec/fixtures/merge_plurals.yml @@ -0,0 +1,6 @@ +fallbacks: false + +translations: + - file: "tmp/i18n-js/merge_plurals.js" + only: + - "*.merge_plurals" diff --git a/spec/fixtures/merge_plurals_with_no_overrides.yml b/spec/fixtures/merge_plurals_with_no_overrides.yml new file mode 100644 index 00000000..c32468d2 --- /dev/null +++ b/spec/fixtures/merge_plurals_with_no_overrides.yml @@ -0,0 +1,4 @@ +translations: + - file: "tmp/i18n-js/merge_plurals_with_no_overrides.js" + only: + - "*.merge_plurals_with_no_overrides" diff --git a/spec/fixtures/merge_plurals_with_partial_overrides.yml b/spec/fixtures/merge_plurals_with_partial_overrides.yml new file mode 100644 index 00000000..60aa495d --- /dev/null +++ b/spec/fixtures/merge_plurals_with_partial_overrides.yml @@ -0,0 +1,4 @@ +translations: + - file: "tmp/i18n-js/merge_plurals_with_partial_overrides.js" + only: + - "*.merge_plurals_with_partial_overrides" diff --git a/spec/fixtures/millions.yml b/spec/fixtures/millions.yml new file mode 100644 index 00000000..e19afc6c --- /dev/null +++ b/spec/fixtures/millions.yml @@ -0,0 +1,4 @@ +translations: + - file: "tmp/i18n-js/millions.js" + only: + - "*.number.human.decimal_units.units" diff --git a/spec/fixtures/multiple_conditions.yml b/spec/fixtures/multiple_conditions.yml new file mode 100755 index 00000000..3a94d9da --- /dev/null +++ b/spec/fixtures/multiple_conditions.yml @@ -0,0 +1,7 @@ +fallbacks: false + +translations: + - file: "tmp/i18n-js/bitsnpieces.js" + only: + - "*.date.formats" + - "*.number.currency" diff --git a/spec/fixtures/multiple_conditions_per_locale.yml b/spec/fixtures/multiple_conditions_per_locale.yml new file mode 100644 index 00000000..5677c0f1 --- /dev/null +++ b/spec/fixtures/multiple_conditions_per_locale.yml @@ -0,0 +1,7 @@ +fallbacks: false + +translations: + - file: "tmp/i18n-js/bits.%{locale}.js" + only: + - "*.date.formats.*" + - "*.number.currency.*" diff --git a/spec/fixtures/multiple_files.yml b/spec/fixtures/multiple_files.yml new file mode 100755 index 00000000..1d55a882 --- /dev/null +++ b/spec/fixtures/multiple_files.yml @@ -0,0 +1,7 @@ +fallbacks: false + +translations: + - file: "tmp/i18n-js/all.js" + only: "*" + - file: "tmp/i18n-js/tudo.js" + only: "*" diff --git a/spec/resources/no_config.yml b/spec/fixtures/no_config.yml old mode 100644 new mode 100755 similarity index 100% rename from spec/resources/no_config.yml rename to spec/fixtures/no_config.yml diff --git a/spec/fixtures/no_scope.yml b/spec/fixtures/no_scope.yml new file mode 100755 index 00000000..ce93d30d --- /dev/null +++ b/spec/fixtures/no_scope.yml @@ -0,0 +1,4 @@ +fallbacks: false + +translations: + - file: "tmp/i18n-js/no_scope.js" diff --git a/spec/fixtures/simple_scope.yml b/spec/fixtures/simple_scope.yml new file mode 100755 index 00000000..1aab4f65 --- /dev/null +++ b/spec/fixtures/simple_scope.yml @@ -0,0 +1,5 @@ +fallbacks: false + +translations: + - file: "tmp/i18n-js/simple_scope.js" + only: "*.date.formats" diff --git a/spec/i18n_spec.js b/spec/i18n_spec.js deleted file mode 100644 index d9a65bb8..00000000 --- a/spec/i18n_spec.js +++ /dev/null @@ -1,820 +0,0 @@ -load("vendor/assets/javascripts/i18n.js"); - -describe("I18n.js", function(){ - before(function() { - I18n.defaultLocale = "en"; - I18n.defaultSeparator = "."; - I18n.locale = null; - - I18n.translations = { - en: { - hello: "Hello World!", - greetings: { - stranger: "Hello stranger!", - name: "Hello {{name}}!" - }, - profile: { - details: "{{name}} is {{age}}-years old" - }, - inbox: { - one: "You have {{count}} message", - other: "You have {{count}} messages", - zero: "You have no messages" - }, - unread: { - one: "You have 1 new message ({{unread}} unread)", - other: "You have {{count}} new messages ({{unread}} unread)", - zero: "You have no new messages ({{unread}} unread)" - }, - number: { - human: { - storage_units: { - units: { - "byte": { - one: "Byte", - other: "Bytes" - }, - - "kb": "KB", - "mb": "MB", - "gb": "GB", - "tb": "TB" - } - } - } - } - }, - - "pt-BR": { - hello: "Olá Mundo!", - date: { - formats: { - "default": "%d/%m/%Y", - "short": "%d de %B", - "long": "%d de %B de %Y" - }, - day_names: ["Domingo", "Segunda", "Terça", "Quarta", "Quinta", "Sexta", "Sábado"], - abbr_day_names: ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"], - month_names: [null, "Janeiro", "Fevereiro", "Março", "Abril", "Maio", "Junho", "Julho", "Agosto", "Setembro", "Outubro", "Novembro", "Dezembro"], - abbr_month_names: [null, "Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"] - }, - number: { - percentage: { - format: { - delimiter: "", - separator: ",", - precision: 2 - } - } - }, - time: { - formats: { - "default": "%A, %d de %B de %Y, %H:%M h", - "short": "%d/%m, %H:%M h", - "long": "%A, %d de %B de %Y, %H:%M h" - }, - am: "AM", - pm: "PM" - } - }, - - "en-US": { - date: { - formats: { - "default": "%d/%m/%Y", - "short": "%d de %B", - "long": "%d de %B de %Y" - }, - day_names: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], - abbr_day_names: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], - month_names: [null, "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], - abbr_month_names: [null, "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"], - meridian: ["am", "pm"] - } - }, - - "de": { - hello: "Hallo Welt!" - }, - - "nb": { - hello: "Hei Verden!" - } - }; - }); - - specify("with default options", function(){ - expect(I18n.defaultLocale).toBeEqualTo("en"); - expect(I18n.locale).toBeEqualTo(null); - expect(I18n.currentLocale()).toBeEqualTo("en"); - }); - - specify("with custom locale", function(){ - I18n.locale = "pt-BR"; - expect(I18n.currentLocale()).toBeEqualTo("pt-BR"); - }); - - specify("aliases", function(){ - expect(I18n.t).toBe(I18n.translate); - expect(I18n.l).toBe(I18n.localize); - expect(I18n.p).toBe(I18n.pluralize); - }); - - specify("translation with single scope", function(){ - expect(I18n.t("hello")).toBeEqualTo("Hello World!"); - }); - - specify("translation as object", function(){ - expect(I18n.t("greetings")).toBeInstanceOf(Object); - }); - - specify("translation with invalid scope shall not block", function(){ - actual = I18n.t("invalid.scope.shall.not.block"); - expected = '[missing "en.invalid.scope.shall.not.block" translation]'; - expect(actual).toBeEqualTo(expected); - }); - - specify("translation for single scope on a custom locale", function(){ - I18n.locale = "pt-BR"; - expect(I18n.t("hello")).toBeEqualTo("Olá Mundo!"); - }); - - specify("translation for multiple scopes", function(){ - expect(I18n.t("greetings.stranger")).toBeEqualTo("Hello stranger!"); - }); - - specify("translation with default locale option", function(){ - expect(I18n.t("hello", {locale: "en"})).toBeEqualTo("Hello World!"); - expect(I18n.t("hello", {locale: "pt-BR"})).toBeEqualTo("Olá Mundo!"); - }); - - specify("translation should fall if locale is missing", function(){ - I18n.locale = "pt-BR"; - expect(I18n.t("greetings.stranger")).toBeEqualTo("[missing \"pt-BR.greetings.stranger\" translation]"); - }); - - specify("translation should handle fallback if I18n.fallbacks == true", function(){ - I18n.locale = "pt-BR"; - I18n.fallbacks = true; - expect(I18n.t("greetings.stranger")).toBeEqualTo("Hello stranger!"); - }); - - specify("translation should handle fallback from unknown locale", function(){ - I18n.locale = "fr"; - I18n.fallbacks = true; - expect(I18n.t("greetings.stranger")).toBeEqualTo("Hello stranger!"); - }); - - specify("translation should handle fallback to less specific locale", function(){ - I18n.locale = "de-DE"; - I18n.fallbacks = true; - expect(I18n.t("hello")).toBeEqualTo("Hallo Welt!"); - }); - - specify("translation should handle fallback via custom rules", function(){ - I18n.locale = "no"; - I18n.fallbacks = true; - I18n.fallbackRules.no = [ "nb" ]; - expect(I18n.t("hello")).toBeEqualTo("Hei Verden!"); - }); - - specify("single interpolation", function(){ - actual = I18n.t("greetings.name", {name: "John Doe"}); - expect(actual).toBeEqualTo("Hello John Doe!"); - }); - - specify("multiple interpolation", function(){ - actual = I18n.t("profile.details", {name: "John Doe", age: 27}); - expect(actual).toBeEqualTo("John Doe is 27-years old"); - }); - - specify("translation with count option", function(){ - expect(I18n.t("inbox", {count: 0})).toBeEqualTo("You have no messages"); - expect(I18n.t("inbox", {count: 1})).toBeEqualTo("You have 1 message"); - expect(I18n.t("inbox", {count: 5})).toBeEqualTo("You have 5 messages"); - }); - - specify("translation with count option and multiple placeholders", function(){ - actual = I18n.t("unread", {unread: 5, count: 1}); - expect(actual).toBeEqualTo("You have 1 new message (5 unread)"); - - actual = I18n.t("unread", {unread: 2, count: 10}); - expect(actual).toBeEqualTo("You have 10 new messages (2 unread)"); - - actual = I18n.t("unread", {unread: 5, count: 0}); - expect(actual).toBeEqualTo("You have no new messages (5 unread)"); - }); - - specify("missing translation with count option", function(){ - actual = I18n.t("invalid", {count: 1}); - expect(actual).toBeEqualTo('[missing "en.invalid" translation]'); - - I18n.translations.en.inbox.one = null; - actual = I18n.t("inbox", {count: 1}); - expect(actual).toBeEqualTo('[missing "en.inbox.one" translation]'); - }); - - specify("pluralization", function(){ - expect(I18n.p(0, "inbox")).toBeEqualTo("You have no messages"); - expect(I18n.p(1, "inbox")).toBeEqualTo("You have 1 message"); - expect(I18n.p(5, "inbox")).toBeEqualTo("You have 5 messages"); - }); - - specify("pluralize should return 'other' scope", function(){ - I18n.translations["en"]["inbox"]["zero"] = null; - expect(I18n.p(0, "inbox")).toBeEqualTo("You have 0 messages"); - }); - - specify("pluralize should return 'zero' scope", function(){ - I18n.translations["en"]["inbox"]["zero"] = "No messages (zero)"; - I18n.translations["en"]["inbox"]["none"] = "No messages (none)"; - - expect(I18n.p(0, "inbox")).toBeEqualTo("No messages (zero)"); - }); - - specify("pluralize should return 'none' scope", function(){ - I18n.translations["en"]["inbox"]["zero"] = null; - I18n.translations["en"]["inbox"]["none"] = "No messages (none)"; - - expect(I18n.p(0, "inbox")).toBeEqualTo("No messages (none)"); - }); - - specify("pluralize with negative values", function(){ - expect(I18n.p(-1, "inbox")).toBeEqualTo("You have -1 message"); - expect(I18n.p(-5, "inbox")).toBeEqualTo("You have -5 messages"); - }); - - specify("pluralize with missing scope", function(){ - expect(I18n.p(-1, "missing")).toBeEqualTo('[missing "en.missing" translation]'); - }); - - specify("pluralize with multiple placeholders", function(){ - actual = I18n.p(1, "unread", {unread: 5}); - expect(actual).toBeEqualTo("You have 1 new message (5 unread)"); - - actual = I18n.p(10, "unread", {unread: 2}); - expect(actual).toBeEqualTo("You have 10 new messages (2 unread)"); - - actual = I18n.p(0, "unread", {unread: 5}); - expect(actual).toBeEqualTo("You have no new messages (5 unread)"); - }); - - specify("pluralize should allow empty strings", function(){ - I18n.translations["en"]["inbox"]["zero"] = ""; - - expect(I18n.p(0, "inbox")).toBeEqualTo(""); - }); - - specify("numbers with default settings", function(){ - expect(I18n.toNumber(1)).toBeEqualTo("1.000"); - expect(I18n.toNumber(12)).toBeEqualTo("12.000"); - expect(I18n.toNumber(123)).toBeEqualTo("123.000"); - expect(I18n.toNumber(1234)).toBeEqualTo("1,234.000"); - expect(I18n.toNumber(12345)).toBeEqualTo("12,345.000"); - expect(I18n.toNumber(123456)).toBeEqualTo("123,456.000"); - expect(I18n.toNumber(1234567)).toBeEqualTo("1,234,567.000"); - expect(I18n.toNumber(12345678)).toBeEqualTo("12,345,678.000"); - expect(I18n.toNumber(123456789)).toBeEqualTo("123,456,789.000"); - }); - - specify("negative numbers with default settings", function(){ - expect(I18n.toNumber(-1)).toBeEqualTo("-1.000"); - expect(I18n.toNumber(-12)).toBeEqualTo("-12.000"); - expect(I18n.toNumber(-123)).toBeEqualTo("-123.000"); - expect(I18n.toNumber(-1234)).toBeEqualTo("-1,234.000"); - expect(I18n.toNumber(-12345)).toBeEqualTo("-12,345.000"); - expect(I18n.toNumber(-123456)).toBeEqualTo("-123,456.000"); - expect(I18n.toNumber(-1234567)).toBeEqualTo("-1,234,567.000"); - expect(I18n.toNumber(-12345678)).toBeEqualTo("-12,345,678.000"); - expect(I18n.toNumber(-123456789)).toBeEqualTo("-123,456,789.000"); - }); - - specify("numbers with partial translation and default options", function(){ - I18n.translations.en.number = { - format: { - precision: 2 - } - }; - - expect(I18n.toNumber(1234)).toBeEqualTo("1,234.00"); - }); - - specify("numbers with full translation and default options", function(){ - I18n.translations.en.number = { - format: { - delimiter: ".", - separator: ",", - precision: 2 - } - }; - - expect(I18n.toNumber(1234)).toBeEqualTo("1.234,00"); - }); - - specify("numbers with some custom options that should be merged with default options", function(){ - expect(I18n.toNumber(1234, {precision: 0})).toBeEqualTo("1,234"); - expect(I18n.toNumber(1234, {separator: '-'})).toBeEqualTo("1,234-000"); - expect(I18n.toNumber(1234, {delimiter: '-'})).toBeEqualTo("1-234.000"); - }); - - specify("numbers considering options", function(){ - options = { - precision: 2, - separator: ",", - delimiter: "." - }; - - expect(I18n.toNumber(1, options)).toBeEqualTo("1,00"); - expect(I18n.toNumber(12, options)).toBeEqualTo("12,00"); - expect(I18n.toNumber(123, options)).toBeEqualTo("123,00"); - expect(I18n.toNumber(1234, options)).toBeEqualTo("1.234,00"); - expect(I18n.toNumber(123456, options)).toBeEqualTo("123.456,00"); - expect(I18n.toNumber(1234567, options)).toBeEqualTo("1.234.567,00"); - expect(I18n.toNumber(12345678, options)).toBeEqualTo("12.345.678,00"); - }); - - specify("numbers with different precisions", function(){ - options = {separator: ".", delimiter: ","}; - - options["precision"] = 2; - expect(I18n.toNumber(1.98, options)).toBeEqualTo("1.98"); - - options["precision"] = 3; - expect(I18n.toNumber(1.98, options)).toBeEqualTo("1.980"); - - options["precision"] = 2; - expect(I18n.toNumber(1.987, options)).toBeEqualTo("1.99"); - - options["precision"] = 1; - expect(I18n.toNumber(1.98, options)).toBeEqualTo("2.0"); - - options["precision"] = 0; - expect(I18n.toNumber(1.98, options)).toBeEqualTo("2"); - }); - - specify("currency with default settings", function(){ - expect(I18n.toCurrency(100.99)).toBeEqualTo("$100.99"); - expect(I18n.toCurrency(1000.99)).toBeEqualTo("$1,000.99"); - }); - - specify("currency with custom settings", function(){ - I18n.translations.en.number = { - currency: { - format: { - format: "%n %u", - unit: "USD", - delimiter: ".", - separator: ",", - precision: 2 - } - } - }; - - expect(I18n.toCurrency(12)).toBeEqualTo("12,00 USD"); - expect(I18n.toCurrency(123)).toBeEqualTo("123,00 USD"); - expect(I18n.toCurrency(1234.56)).toBeEqualTo("1.234,56 USD"); - }); - - specify("currency with custom settings and partial overriding", function(){ - I18n.translations.en.number = { - currency: { - format: { - format: "%n %u", - unit: "USD", - delimiter: ".", - separator: ",", - precision: 2 - } - } - }; - - expect(I18n.toCurrency(12, {precision: 0})).toBeEqualTo("12 USD"); - expect(I18n.toCurrency(123, {unit: "bucks"})).toBeEqualTo("123,00 bucks"); - }); - - specify("currency with some custom options that should be merged with default options", function(){ - expect(I18n.toCurrency(1234, {precision: 0})).toBeEqualTo("$1,234"); - expect(I18n.toCurrency(1234, {unit: "º"})).toBeEqualTo("º1,234.00"); - expect(I18n.toCurrency(1234, {separator: "-"})).toBeEqualTo("$1,234-00"); - expect(I18n.toCurrency(1234, {delimiter: "-"})).toBeEqualTo("$1-234.00"); - expect(I18n.toCurrency(1234, {format: "%u %n"})).toBeEqualTo("$ 1,234.00"); - }); - - specify("localize numbers", function(){ - expect(I18n.l("number", 1234567)).toBeEqualTo("1,234,567.000"); - }); - - specify("localize currency", function(){ - expect(I18n.l("currency", 1234567)).toBeEqualTo("$1,234,567.00"); - }); - - specify("parse date", function(){ - expected = new Date(2009, 0, 24, 0, 0, 0); - actual = I18n.parseDate("2009-01-24"); - expect(actual.toString()).toBeEqualTo(expected.toString()); - - expected = new Date(2009, 0, 24, 0, 15, 0); - actual = I18n.parseDate("2009-01-24 00:15:00"); - expect(actual.toString()).toBeEqualTo(expected.toString()); - - expected = new Date(2009, 0, 24, 0, 0, 15); - actual = I18n.parseDate("2009-01-24 00:00:15"); - expect(actual.toString()).toBeEqualTo(expected.toString()); - - expected = new Date(2009, 0, 24, 15, 33, 44); - actual = I18n.parseDate("2009-01-24 15:33:44"); - expect(actual.toString()).toBeEqualTo(expected.toString()); - - expected = new Date(2009, 0, 24, 0, 0, 0); - actual = I18n.parseDate(expected.getTime()); - expect(actual.toString()).toBeEqualTo(expected.toString()); - - expected = new Date(2009, 0, 24, 0, 0, 0); - actual = I18n.parseDate("01/24/2009"); - expect(actual.toString()).toBeEqualTo(expected.toString()); - - expected = new Date(2009, 0, 24, 14, 33, 55); - actual = I18n.parseDate(expected).toString(); - expect(actual).toBeEqualTo(expected.toString()); - - expected = new Date(2009, 0, 24, 15, 33, 44); - actual = I18n.parseDate("2009-01-24T15:33:44"); - expect(actual.toString()).toBeEqualTo(expected.toString()); - - expected = new Date(Date.UTC(2011, 6, 20, 12, 51, 55)); - actual = I18n.parseDate("2011-07-20T12:51:55+0000"); - expect(actual.toString()).toBeEqualTo(expected.toString()); - - expected = new Date(Date.UTC(2011, 6, 20, 13, 03, 39)); - actual = I18n.parseDate("Wed Jul 20 13:03:39 +0000 2011"); - expect(actual.toString()).toBeEqualTo(expected.toString()); - - expected = new Date(Date.UTC(2009, 0, 24, 15, 33, 44)); - actual = I18n.parseDate("2009-01-24T15:33:44Z"); - expect(actual.toString()).toBeEqualTo(expected.toString()); - }); - - specify("date formatting", function(){ - I18n.locale = "pt-BR"; - - // 2009-04-26 19:35:44 (Sunday) - var date = new Date(2009, 3, 26, 19, 35, 44); - - // short week day - expect(I18n.strftime(date, "%a")).toBeEqualTo("Dom"); - - // full week day - expect(I18n.strftime(date, "%A")).toBeEqualTo("Domingo"); - - // short month - expect(I18n.strftime(date, "%b")).toBeEqualTo("Abr"); - - // full month - expect(I18n.strftime(date, "%B")).toBeEqualTo("Abril"); - - // day - expect(I18n.strftime(date, "%d")).toBeEqualTo("26"); - - // 24-hour - expect(I18n.strftime(date, "%H")).toBeEqualTo("19"); - - // 12-hour - expect(I18n.strftime(date, "%I")).toBeEqualTo("07"); - - // month - expect(I18n.strftime(date, "%m")).toBeEqualTo("04"); - - // minutes - expect(I18n.strftime(date, "%M")).toBeEqualTo("35"); - - // meridian - expect(I18n.strftime(date, "%p")).toBeEqualTo("PM"); - - // seconds - expect(I18n.strftime(date, "%S")).toBeEqualTo("44"); - - // week day - expect(I18n.strftime(date, "%w")).toBeEqualTo("0"); - - // short year - expect(I18n.strftime(date, "%y")).toBeEqualTo("09"); - - // full year - expect(I18n.strftime(date, "%Y")).toBeEqualTo("2009"); - }); - - specify("date formatting without padding", function(){ - I18n.locale = "pt-BR"; - - // 2009-04-26 19:35:44 (Sunday) - var date = new Date(2009, 3, 9, 7, 8, 9); - - // 24-hour without padding - expect(I18n.strftime(date, "%-H")).toBeEqualTo("7"); - - // 12-hour without padding - expect(I18n.strftime(date, "%-I")).toBeEqualTo("7"); - - // minutes without padding - expect(I18n.strftime(date, "%-M")).toBeEqualTo("8"); - - // seconds without padding - expect(I18n.strftime(date, "%-S")).toBeEqualTo("9"); - - // short year without padding - expect(I18n.strftime(date, "%-y")).toBeEqualTo("9"); - - // month without padding - expect(I18n.strftime(date, "%-m")).toBeEqualTo("4"); - - // day without padding - expect(I18n.strftime(date, "%-d")).toBeEqualTo("9"); - expect(I18n.strftime(date, "%e")).toBeEqualTo("9"); - }); - - specify("date formatting with padding", function(){ - I18n.locale = "pt-BR"; - - // 2009-04-26 19:35:44 (Sunday) - var date = new Date(2009, 3, 9, 7, 8, 9); - - // 24-hour - expect(I18n.strftime(date, "%H")).toBeEqualTo("07"); - - // 12-hour - expect(I18n.strftime(date, "%I")).toBeEqualTo("07"); - - // minutes - expect(I18n.strftime(date, "%M")).toBeEqualTo("08"); - - // seconds - expect(I18n.strftime(date, "%S")).toBeEqualTo("09"); - - // short year - expect(I18n.strftime(date, "%y")).toBeEqualTo("09"); - - // month - expect(I18n.strftime(date, "%m")).toBeEqualTo("04"); - - // day - expect(I18n.strftime(date, "%d")).toBeEqualTo("09"); - }); - - specify("date formatting with negative time zone", function(){ - I18n.locale = "pt-BR"; - var date = new Date(2009, 3, 26, 19, 35, 44); - stub(date, "getTimezoneOffset()", 345); - - expect(I18n.strftime(date, "%z")).toMatch(/^(\+|-)[\d]{4}$/); - expect(I18n.strftime(date, "%z")).toBeEqualTo("-0545"); - }); - - specify("date formatting with positive time zone", function(){ - I18n.locale = "pt-BR"; - var date = new Date(2009, 3, 26, 19, 35, 44); - stub(date, "getTimezoneOffset()", -345); - - expect(I18n.strftime(date, "%z")).toMatch(/^(\+|-)[\d]{4}$/); - expect(I18n.strftime(date, "%z")).toBeEqualTo("+0545"); - }); - - specify("date formatting with custom meridian", function(){ - I18n.locale = "en-US"; - var date = new Date(2009, 3, 26, 19, 35, 44); - expect(I18n.strftime(date, "%p")).toBeEqualTo("pm"); - }); - - specify("date formatting meridian boundaries", function(){ - I18n.locale = "en-US"; - var date = new Date(2009, 3, 26, 0, 35, 44); - expect(I18n.strftime(date, "%p")).toBeEqualTo("am"); - - date = new Date(2009, 3, 26, 12, 35, 44); - expect(I18n.strftime(date, "%p")).toBeEqualTo("pm"); - }); - - specify("date formatting hour12 values", function(){ - I18n.locale = "pt-BR"; - var date = new Date(2009, 3, 26, 19, 35, 44); - expect(I18n.strftime(date, "%I")).toBeEqualTo("07"); - - date = new Date(2009, 3, 26, 12, 35, 44); - expect(I18n.strftime(date, "%I")).toBeEqualTo("12"); - - date = new Date(2009, 3, 26, 0, 35, 44); - expect(I18n.strftime(date, "%I")).toBeEqualTo("12"); - }); - - specify("localize date strings", function(){ - I18n.locale = "pt-BR"; - - expect(I18n.l("date.formats.default", "2009-11-29")).toBeEqualTo("29/11/2009"); - expect(I18n.l("date.formats.short", "2009-01-07")).toBeEqualTo("07 de Janeiro"); - expect(I18n.l("date.formats.long", "2009-01-07")).toBeEqualTo("07 de Janeiro de 2009"); - }); - - specify("localize time strings", function(){ - I18n.locale = "pt-BR"; - - expect(I18n.l("time.formats.default", "2009-11-29 15:07:59")).toBeEqualTo("Domingo, 29 de Novembro de 2009, 15:07 h"); - expect(I18n.l("time.formats.short", "2009-01-07 09:12:35")).toBeEqualTo("07/01, 09:12 h"); - expect(I18n.l("time.formats.long", "2009-11-29 15:07:59")).toBeEqualTo("Domingo, 29 de Novembro de 2009, 15:07 h"); - }); - - specify("localize percentage", function(){ - I18n.locale = "pt-BR"; - expect(I18n.l("percentage", 123.45)).toBeEqualTo("123,45%"); - }); - - specify("default value for simple translation", function(){ - actual = I18n.t("warning", {defaultValue: "Warning!"}); - expect(actual).toBeEqualTo("Warning!"); - }); - - specify("default value for unknown locale", function(){ - I18n.locale = "fr"; - actual = I18n.t("warning", {defaultValue: "Warning!"}); - expect(actual).toBeEqualTo("Warning!"); - }); - - specify("default value with interpolation", function(){ - actual = I18n.t( - "alert", - {defaultValue: "Attention! {{message}}", message: "You're out of quota!"} - ); - - expect(actual).toBeEqualTo("Attention! You're out of quota!"); - }); - - specify("default value should not be used when scope exist", function(){ - actual = I18n.t("hello", {defaultValue: "What's up?"}); - expect(actual).toBeEqualTo("Hello World!"); - }); - - specify("default value for pluralize", function(){ - options = {defaultValue: { - none: "No things here!", - one: "There is {{count}} thing here!", - other: "There are {{count}} things here!" - }}; - - expect(I18n.p(0, "things", options)).toBeEqualTo("No things here!"); - expect(I18n.p(1, "things", options)).toBeEqualTo("There is 1 thing here!"); - expect(I18n.p(5, "things", options)).toBeEqualTo("There are 5 things here!"); - }); - - specify("default value for pluralize should not be used when scope exist", function(){ - options = {defaultValue: { - none: "No things here!", - one: "There is {{count}} thing here!", - other: "There are {{count}} things here!" - }}; - - expect(I18n.pluralize(0, "inbox", options)).toBeEqualTo("You have no messages"); - expect(I18n.pluralize(1, "inbox", options)).toBeEqualTo("You have 1 message"); - expect(I18n.pluralize(5, "inbox", options)).toBeEqualTo("You have 5 messages"); - }); - - specify("prepare options", function(){ - options = I18n.prepareOptions( - {name: "Mary Doe"}, - {name: "John Doe", role: "user"} - ); - - expect(options["name"]).toBeEqualTo("Mary Doe"); - expect(options["role"]).toBeEqualTo("user"); - }); - - specify("prepare options with multiple options", function(){ - options = I18n.prepareOptions( - {name: "Mary Doe"}, - {name: "John Doe", role: "user"}, - {age: 33}, - {email: "mary@doe.com", url: "http://marydoe.com"}, - {role: "admin", email: "john@doe.com"} - ); - - expect(options["name"]).toBeEqualTo("Mary Doe"); - expect(options["role"]).toBeEqualTo("user"); - expect(options["age"]).toBeEqualTo(33); - expect(options["email"]).toBeEqualTo("mary@doe.com"); - expect(options["url"]).toBeEqualTo("http://marydoe.com"); - }); - - specify("prepare options should return an empty hash when values are null", function(){ - expect({}).toBeEqualTo(I18n.prepareOptions(null, null)); - }); - - specify("percentage with defaults", function(){ - expect(I18n.toPercentage(1234)).toBeEqualTo("1234.000%"); - }); - - specify("percentage with custom options", function(){ - actual = I18n.toPercentage(1234, {delimiter: "_", precision: 0}); - expect(actual).toBeEqualTo("1_234%"); - }); - - specify("percentage with translation", function(){ - I18n.translations.en.number = { - percentage: { - format: { - precision: 2, - delimiter: ".", - separator: "," - } - } - }; - - expect(I18n.toPercentage(1234)).toBeEqualTo("1.234,00%"); - }); - - specify("percentage with translation and custom options", function(){ - I18n.translations.en.number = { - percentage: { - format: { - precision: 2, - delimiter: ".", - separator: "," - } - } - }; - - actual = I18n.toPercentage(1234, {precision: 4, delimiter: "-", separator: "+"}); - expect(actual).toBeEqualTo("1-234+0000%"); - }); - - specify("scope option as string", function(){ - actual = I18n.t("stranger", {scope: "greetings"}); - expect(actual).toBeEqualTo("Hello stranger!"); - }); - - specify("scope as array", function(){ - actual = I18n.t(["greetings", "stranger"]); - expect(actual).toBeEqualTo("Hello stranger!"); - }); - - specify("new placeholder syntax", function(){ - I18n.translations["en"]["new_syntax"] = "Hi %{name}!"; - actual = I18n.t("new_syntax", {name: "John"}); - expect(actual).toBeEqualTo("Hi John!"); - }); - - specify("return translation for custom scope separator", function(){ - I18n.defaultSeparator = "•"; - actual = I18n.t("greetings•stranger"); - expect(actual).toBeEqualTo("Hello stranger!"); - }); - - specify("return number as human size", function(){ - kb = 1024; - - expect(I18n.toHumanSize(1)).toBeEqualTo("1Byte"); - expect(I18n.toHumanSize(100)).toBeEqualTo("100Bytes"); - - expect(I18n.toHumanSize(kb)).toBeEqualTo("1KB"); - expect(I18n.toHumanSize(kb * 1.5)).toBeEqualTo("1.5KB"); - - expect(I18n.toHumanSize(kb * kb)).toBeEqualTo("1MB"); - expect(I18n.toHumanSize(kb * kb * 1.5)).toBeEqualTo("1.5MB"); - - expect(I18n.toHumanSize(kb * kb * kb)).toBeEqualTo("1GB"); - expect(I18n.toHumanSize(kb * kb * kb * 1.5)).toBeEqualTo("1.5GB"); - - expect(I18n.toHumanSize(kb * kb * kb * kb)).toBeEqualTo("1TB"); - expect(I18n.toHumanSize(kb * kb * kb * kb * 1.5)).toBeEqualTo("1.5TB"); - - expect(I18n.toHumanSize(kb * kb * kb * kb * kb)).toBeEqualTo("1024TB"); - }); - - specify("return number as human size using custom options", function(){ - expect(I18n.toHumanSize(1024 * 1.6, {precision: 0})).toBeEqualTo("2KB"); - }); - - specify("return number without insignificant zeros", function(){ - options = {precision: 4, strip_insignificant_zeros: true}; - - expect(I18n.toNumber(65, options)).toBeEqualTo("65"); - expect(I18n.toNumber(1.2, options)).toBeEqualTo("1.2"); - expect(I18n.toCurrency(1.2, options)).toBeEqualTo("$1.2"); - expect(I18n.toHumanSize(1.2, options)).toBeEqualTo("1.2Bytes"); - }); - - specify("return plural key for given count for english locale", function(){ - expect(I18n.pluralizationRules.en(0)).toBeEqualTo(["zero", "none", "other"]); - expect(I18n.pluralizationRules.en(1)).toBeEqualTo("one"); - expect(I18n.pluralizationRules.en(2)).toBeEqualTo("other"); - }); - - specify("return given pluralizer", function(){ - I18n.pluralizationRules.en = "en"; - expect(I18n.pluralizer("ru")).toBeEqualTo("en"); - I18n.pluralizationRules.ru = "ru"; - expect(I18n.pluralizer("ru")).toBeEqualTo("ru"); - }); - - specify("find and translate valid node", function(){ - expect(I18n.findAndTranslateValidNode(["one", "other"], {one: "one", other: "other"})).toBeEqualTo("one"); - expect(I18n.findAndTranslateValidNode(["one", "other"], {other: "other"})).toBeEqualTo("other"); - expect(I18n.findAndTranslateValidNode(["one"], {})).toBeEqualTo(null); - }); -}); diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb deleted file mode 100644 index 52d1388f..00000000 --- a/spec/i18n_spec.rb +++ /dev/null @@ -1,205 +0,0 @@ -require "spec_helper" - -if File.basename(Rails.root) != "tmp" - abort <<-TXT -\e[31;5m -WARNING: That will remove your project! -Please go to #{File.expand_path(File.dirname(__FILE__) + "/..")} and run `rake spec`\e[0m -TXT -end - -describe SimplesIdeias::I18n do - before do - # Remove temporary directory if already present - FileUtils.rm_r(Rails.root) if File.exist?(Rails.root) - - # Create temporary directory to test the files generation - %w( config public/javascripts ).each do |path| - FileUtils.mkdir_p Rails.root.join(path) - end - - # Overwrite defaut locales path to use fixtures - I18n.load_path = [File.dirname(__FILE__) + "/resources/locales.yml"] - end - - after do - # Remove temporary directory - FileUtils.rm_r(Rails.root) - end - - it "copies the configuration file" do - File.should_not be_file(SimplesIdeias::I18n.config_file) - SimplesIdeias::I18n.setup! - File.should be_file(SimplesIdeias::I18n.config_file) - end - - it "keeps existing configuration file" do - File.open(SimplesIdeias::I18n.config_file, "w+") {|f| f << "ORIGINAL"} - SimplesIdeias::I18n.setup! - - File.read(SimplesIdeias::I18n.config_file).should == "ORIGINAL" - end - - it "copies JavaScript library" do - path = Rails.root.join("public/javascripts/i18n.js") - - File.should_not be_file(path) - SimplesIdeias::I18n.setup! - File.should be_file(path) - end - - it "loads configuration file" do - set_config "default.yml" - SimplesIdeias::I18n.setup! - - SimplesIdeias::I18n.config?.should be_true - SimplesIdeias::I18n.config.should be_kind_of(HashWithIndifferentAccess) - SimplesIdeias::I18n.config.should_not be_empty - end - - it "sets empty hash as configuration when no file is found" do - SimplesIdeias::I18n.config?.should be_false - SimplesIdeias::I18n.config.should == {} - end - - it "exports messages to default path when configuration file doesn't exist" do - SimplesIdeias::I18n.export! - Rails.root.join(SimplesIdeias::I18n.export_dir, "translations.js").should be_file - end - - it "exports messages using custom output path" do - set_config "custom_path.yml" - SimplesIdeias::I18n.should_receive(:save).with(translations, "public/javascripts/translations/all.js") - SimplesIdeias::I18n.export! - end - - it "sets default scope to * when not specified" do - set_config "no_scope.yml" - SimplesIdeias::I18n.should_receive(:save).with(translations, "public/javascripts/no_scope.js") - SimplesIdeias::I18n.export! - end - - it "exports to multiple files" do - set_config "multiple_files.yml" - SimplesIdeias::I18n.export! - - File.should be_file(Rails.root.join("public/javascripts/all.js")) - File.should be_file(Rails.root.join("public/javascripts/tudo.js")) - end - - it "ignores an empty config file" do - set_config "no_config.yml" - SimplesIdeias::I18n.export! - Rails.root.join(SimplesIdeias::I18n.export_dir, "translations.js").should be_file - end - - it "exports to a JS file per available locale" do - set_config "js_file_per_locale.yml" - SimplesIdeias::I18n.export! - - File.should be_file(Rails.root.join("public/javascripts/i18n/en.js")) - end - - it "exports with multiple conditions" do - set_config "multiple_conditions.yml" - SimplesIdeias::I18n.export! - File.should be_file(Rails.root.join("public/javascripts/bitsnpieces.js")) - end - - it "filters translations using scope *.date.formats" do - result = SimplesIdeias::I18n.filter(translations, "*.date.formats") - result[:en][:date].keys.should == [:formats] - result[:fr][:date].keys.should == [:formats] - end - - it "filters translations using scope [*.date.formats, *.number.currency.format]" do - result = SimplesIdeias::I18n.scoped_translations(["*.date.formats", "*.number.currency.format"]) - result[:en].keys.collect(&:to_s).sort.should == %w[ date number ] - result[:fr].keys.collect(&:to_s).sort.should == %w[ date number ] - end - - it "filters translations using multi-star scope" do - result = SimplesIdeias::I18n.scoped_translations("*.*.formats") - - result[:en].keys.collect(&:to_s).sort.should == %w[ date time ] - result[:fr].keys.collect(&:to_s).sort.should == %w[ date time ] - - result[:en][:date].keys.should == [:formats] - result[:en][:time].keys.should == [:formats] - - result[:fr][:date].keys.should == [:formats] - result[:fr][:time].keys.should == [:formats] - end - - it "filters translations using alternated stars" do - result = SimplesIdeias::I18n.scoped_translations("*.admin.*.title") - - result[:en][:admin].keys.collect(&:to_s).sort.should == %w[ edit show ] - result[:fr][:admin].keys.collect(&:to_s).sort.should == %w[ edit show ] - - result[:en][:admin][:show][:title].should == "Show" - result[:fr][:admin][:show][:title].should == "Visualiser" - - result[:en][:admin][:edit][:title].should == "Edit" - result[:fr][:admin][:edit][:title].should == "Editer" - end - - it "performs a deep merge" do - target = {:a => {:b => 1}} - result = SimplesIdeias::I18n.deep_merge(target, {:a => {:c => 2}}) - - result[:a].should == {:b => 1, :c => 2} - end - - it "performs a banged deep merge" do - target = {:a => {:b => 1}} - SimplesIdeias::I18n.deep_merge!(target, {:a => {:c => 2}}) - - target[:a].should == {:b => 1, :c => 2} - end - - it "updates the javascript library" do - FakeWeb.register_uri(:get, "https://raw.github.com/fnando/i18n-js/master/vendor/assets/javascripts/i18n.js", :body => "UPDATED") - - SimplesIdeias::I18n.setup! - SimplesIdeias::I18n.update! - File.read(SimplesIdeias::I18n.javascript_file).should == "UPDATED" - end - - describe "#export_dir" do - it "detects asset pipeline support" do - SimplesIdeias::I18n.stub :has_asset_pipeline? => true - SimplesIdeias::I18n.export_dir == "vendor/assets/javascripts" - end - - it "detects older Rails" do - SimplesIdeias::I18n.stub :has_asset_pipeline? => false - SimplesIdeias::I18n.export_dir.to_s.should == "public/javascripts" - end - end - - describe "#has_asset_pipeline?" do - it "detects support" do - Rails.stub_chain(:configuration, :assets, :enabled => true) - SimplesIdeias::I18n.should have_asset_pipeline - end - - it "skips support" do - SimplesIdeias::I18n.should_not have_asset_pipeline - end - end - - private - # Set the configuration as the current one - def set_config(path) - config = HashWithIndifferentAccess.new(YAML.load_file(File.dirname(__FILE__) + "/resources/#{path}")) - SimplesIdeias::I18n.stub(:config? => true) - SimplesIdeias::I18n.stub(:config => config) - end - - # Shortcut to SimplesIdeias::I18n.translations - def translations - SimplesIdeias::I18n.translations - end -end - diff --git a/spec/js/currency.spec.js b/spec/js/currency.spec.js new file mode 100644 index 00000000..aa02a811 --- /dev/null +++ b/spec/js/currency.spec.js @@ -0,0 +1,62 @@ +var I18n = require("../../app/assets/javascripts/i18n") + , Translations = require("./translations") +; + +describe("Currency", function(){ + var actual, expected; + + beforeEach(function() { + I18n.reset(); + I18n.translations = Translations(); + }); + + it("formats currency with default settings", function(){ + expect(I18n.toCurrency(100.99)).toEqual("$100.99"); + expect(I18n.toCurrency(1000.99)).toEqual("$1,000.99"); + expect(I18n.toCurrency(-1000)).toEqual("-$1,000.00"); + }); + + it("formats currency with custom settings", function(){ + I18n.translations.en.number = { + currency: { + format: { + format: "%n %u", + unit: "USD", + delimiter: ".", + separator: ",", + precision: 2 + } + } + }; + + expect(I18n.toCurrency(12)).toEqual("12,00 USD"); + expect(I18n.toCurrency(123)).toEqual("123,00 USD"); + expect(I18n.toCurrency(1234.56)).toEqual("1.234,56 USD"); + }); + + it("formats currency with custom settings and partial overriding", function(){ + I18n.translations.en.number = { + currency: { + format: { + format: "%n %u", + unit: "USD", + delimiter: ".", + separator: ",", + precision: 2 + } + } + }; + + expect(I18n.toCurrency(12, {precision: 0})).toEqual("12 USD"); + expect(I18n.toCurrency(123, {unit: "bucks"})).toEqual("123,00 bucks"); + }); + + it("formats currency with some custom options that should be merged with default options", function(){ + expect(I18n.toCurrency(1234, {precision: 0})).toEqual("$1,234"); + expect(I18n.toCurrency(1234, {unit: "º"})).toEqual("º1,234.00"); + expect(I18n.toCurrency(1234, {separator: "-"})).toEqual("$1,234-00"); + expect(I18n.toCurrency(1234, {delimiter: "-"})).toEqual("$1-234.00"); + expect(I18n.toCurrency(1234, {format: "%u %n"})).toEqual("$ 1,234.00"); + expect(I18n.toCurrency(-123, {sign_first: false})).toEqual("$-123.00"); + }); +}); diff --git a/spec/js/current_locale.spec.js b/spec/js/current_locale.spec.js new file mode 100644 index 00000000..0025e255 --- /dev/null +++ b/spec/js/current_locale.spec.js @@ -0,0 +1,19 @@ +var I18n = require("../../app/assets/javascripts/i18n"); + +describe("Current locale", function(){ + beforeEach(function(){ + I18n.reset(); + }); + + it("returns I18n.locale", function(){ + I18n.locale = "pt-BR"; + expect(I18n.currentLocale()).toEqual("pt-BR"); + }); + + it("returns I18n.defaultLocale", function(){ + I18n.locale = null; + I18n.defaultLocale = "pt-BR"; + + expect(I18n.currentLocale()).toEqual("pt-BR"); + }); +}); diff --git a/spec/js/dates.spec.js b/spec/js/dates.spec.js new file mode 100644 index 00000000..a918d7ff --- /dev/null +++ b/spec/js/dates.spec.js @@ -0,0 +1,276 @@ +var I18n = require("../../app/assets/javascripts/i18n") + , Translations = require("./translations") +; + +describe("Dates", function(){ + var actual, expected; + + beforeEach(function() { + I18n.reset(); + I18n.translations = Translations(); + }); + + it("parses date", function(){ + expected = new Date(2009, 0, 24, 0, 0, 0); + actual = I18n.parseDate("2009-01-24"); + expect(actual.toString()).toEqual(expected.toString()); + + expected = new Date(2009, 0, 24, 0, 15, 0); + actual = I18n.parseDate("2009-01-24 00:15:00"); + expect(actual.toString()).toEqual(expected.toString()); + + expected = new Date(2009, 0, 24, 0, 0, 15); + actual = I18n.parseDate("2009-01-24 00:00:15"); + expect(actual.toString()).toEqual(expected.toString()); + + expected = new Date(2009, 0, 24, 15, 33, 44); + actual = I18n.parseDate("2009-01-24 15:33:44"); + expect(actual.toString()).toEqual(expected.toString()); + + expected = new Date(2009, 0, 24, 0, 0, 0); + actual = I18n.parseDate(expected.getTime()); + expect(actual.toString()).toEqual(expected.toString()); + + expected = new Date(2009, 0, 24, 0, 0, 0); + actual = I18n.parseDate("01/24/2009"); + expect(actual.toString()).toEqual(expected.toString()); + + expected = new Date(2009, 0, 24, 14, 33, 55); + actual = I18n.parseDate(expected).toString(); + expect(actual).toEqual(expected.toString()); + + expected = new Date(2009, 0, 24, 15, 33, 44); + actual = I18n.parseDate("2009-01-24T15:33:44"); + expect(actual.toString()).toEqual(expected.toString()); + + expected = new Date(Date.UTC(2011, 6, 20, 12, 51, 55)); + actual = I18n.parseDate("2011-07-20T12:51:55+0000"); + expect(actual.toString()).toEqual(expected.toString()); + + expected = new Date(Date.UTC(2011, 6, 20, 12, 51, 55)); + actual = I18n.parseDate("2011-07-20T12:51:55+00:00"); + expect(actual.toString()).toEqual(expected.toString()); + + expected = new Date(Date.UTC(2011, 6, 20, 13, 03, 39)); + actual = I18n.parseDate("Wed Jul 20 13:03:39 +0000 2011"); + expect(actual.toString()).toEqual(expected.toString()); + + expected = new Date(Date.UTC(2009, 0, 24, 15, 33, 44)); + actual = I18n.parseDate("2009-01-24T15:33:44Z"); + expect(actual.toString()).toEqual(expected.toString()); + + expected = new Date(Date.UTC(2009, 0, 24, 15, 34, 44, 200)); + actual = I18n.parseDate("2009-01-24T15:34:44.200Z"); + expect(actual.toString()).toEqual(expected.toString()); + expect(actual.getMilliseconds()).toEqual(expected.getMilliseconds()) + + expected = new Date(Date.UTC(2009, 0, 24, 15, 34, 45, 200)); + actual = I18n.parseDate("2009-01-24T15:34:45.200+0000"); + expect(actual.toString()).toEqual(expected.toString()); + expect(actual.getMilliseconds()).toEqual(expected.getMilliseconds()) + + expected = new Date(Date.UTC(2009, 0, 24, 15, 34, 46, 200)); + actual = I18n.parseDate("2009-01-24T15:34:46.200+00:00"); + expect(actual.toString()).toEqual(expected.toString()); + expect(actual.getMilliseconds()).toEqual(expected.getMilliseconds()) + }); + + it("formats date", function(){ + I18n.locale = "pt-BR"; + + // 2009-04-26 19:35:44 (Sunday) + var date = new Date(2009, 3, 26, 19, 35, 44); + + // short week day + expect(I18n.strftime(date, "%a")).toEqual("Dom"); + + // full week day + expect(I18n.strftime(date, "%A")).toEqual("Domingo"); + + // short month + expect(I18n.strftime(date, "%b")).toEqual("Abr"); + + // full month + expect(I18n.strftime(date, "%B")).toEqual("Abril"); + + // day + expect(I18n.strftime(date, "%d")).toEqual("26"); + + // 24-hour + expect(I18n.strftime(date, "%H")).toEqual("19"); + + // 12-hour + expect(I18n.strftime(date, "%I")).toEqual("07"); + + // month + expect(I18n.strftime(date, "%m")).toEqual("04"); + + // minutes + expect(I18n.strftime(date, "%M")).toEqual("35"); + + // meridian + expect(I18n.strftime(date, "%p")).toEqual("PM"); + + // seconds + expect(I18n.strftime(date, "%S")).toEqual("44"); + + // week day + expect(I18n.strftime(date, "%w")).toEqual("0"); + + // short year + expect(I18n.strftime(date, "%y")).toEqual("09"); + + // full year + expect(I18n.strftime(date, "%Y")).toEqual("2009"); + }); + + it("formats date without padding", function(){ + I18n.locale = "pt-BR"; + + // 2009-04-26 19:35:44 (Sunday) + var date = new Date(2009, 3, 9, 7, 8, 9); + + // 24-hour without padding + expect(I18n.strftime(date, "%-H")).toEqual("7"); + expect(I18n.strftime(date, "%k")).toEqual("7"); + + // 12-hour without padding + expect(I18n.strftime(date, "%-I")).toEqual("7"); + expect(I18n.strftime(date, "%l")).toEqual("7"); + + // minutes without padding + expect(I18n.strftime(date, "%-M")).toEqual("8"); + + // seconds without padding + expect(I18n.strftime(date, "%-S")).toEqual("9"); + + // short year without padding + expect(I18n.strftime(date, "%-y")).toEqual("9"); + + // month without padding + expect(I18n.strftime(date, "%-m")).toEqual("4"); + + // day without padding + expect(I18n.strftime(date, "%-d")).toEqual("9"); + expect(I18n.strftime(date, "%e")).toEqual("9"); + }); + + it("formats date with padding", function(){ + I18n.locale = "pt-BR"; + + // 2009-04-26 19:35:44 (Sunday) + var date = new Date(2009, 3, 9, 7, 8, 9); + + // 24-hour + expect(I18n.strftime(date, "%H")).toEqual("07"); + + // 12-hour + expect(I18n.strftime(date, "%I")).toEqual("07"); + + // minutes + expect(I18n.strftime(date, "%M")).toEqual("08"); + + // seconds + expect(I18n.strftime(date, "%S")).toEqual("09"); + + // short year + expect(I18n.strftime(date, "%y")).toEqual("09"); + + // month + expect(I18n.strftime(date, "%m")).toEqual("04"); + + // day + expect(I18n.strftime(date, "%d")).toEqual("09"); + }); + + it("formats date with negative time zone", function(){ + I18n.locale = "pt-BR"; + var date = new Date(2009, 3, 26, 19, 35, 44); + + spyOn(date, "getTimezoneOffset").andReturn(345); + + expect(I18n.strftime(date, "%z")).toMatch(/^(\+|-)[\d]{4}$/); + expect(I18n.strftime(date, "%Z")).toMatch(/^(\+|-)[\d]{4}$/); + expect(I18n.strftime(date, "%z")).toEqual("-0545"); + expect(I18n.strftime(date, "%Z")).toEqual("-0545"); + }); + + it("formats date with positive time zone", function(){ + I18n.locale = "pt-BR"; + var date = new Date(2009, 3, 26, 19, 35, 44); + + spyOn(date, "getTimezoneOffset").andReturn(-345); + + expect(I18n.strftime(date, "%z")).toMatch(/^(\+|-)[\d]{4}$/); + expect(I18n.strftime(date, "%Z")).toMatch(/^(\+|-)[\d]{4}$/); + expect(I18n.strftime(date, "%z")).toEqual("+0545"); + expect(I18n.strftime(date, "%Z")).toEqual("+0545"); + }); + + it("formats date with custom meridian", function(){ + I18n.locale = "en-US"; + var date = new Date(2009, 3, 26, 19, 35, 44); + expect(I18n.strftime(date, "%p")).toEqual("pm"); + expect(I18n.strftime(date, "%P")).toEqual("pm"); + }); + + it("formats date with meridian boundaries", function(){ + I18n.locale = "en-US"; + var date = new Date(2009, 3, 26, 0, 35, 44); + expect(I18n.strftime(date, "%p")).toEqual("am"); + expect(I18n.strftime(date, "%P")).toEqual("am"); + + date = new Date(2009, 3, 26, 12, 35, 44); + expect(I18n.strftime(date, "%p")).toEqual("pm"); + expect(I18n.strftime(date, "%P")).toEqual("pm"); + }); + + it("formats date using 12-hours format", function(){ + I18n.locale = "pt-BR"; + var date = new Date(2009, 3, 26, 19, 35, 44); + expect(I18n.strftime(date, "%I")).toEqual("07"); + + date = new Date(2009, 3, 26, 12, 35, 44); + expect(I18n.strftime(date, "%I")).toEqual("12"); + + date = new Date(2009, 3, 26, 0, 35, 44); + expect(I18n.strftime(date, "%I")).toEqual("12"); + }); + + it("defaults to English", function() { + I18n.locale = "wk"; + + var date = new Date(2009, 3, 26, 19, 35, 44); + expect(I18n.strftime(date, "%a")).toEqual("Sun"); + }); + + it("applies locale fallback", function(){ + I18n.defaultLocale = "en-US"; + I18n.locale = "de"; + + var date = new Date(2009, 3, 26, 19, 35, 44); + expect(I18n.strftime(date, "%A")).toEqual("Sonntag"); + + date = new Date(2009, 3, 26, 19, 35, 44); + expect(I18n.strftime(date, "%a")).toEqual("Sun"); + }); + + it("uses time as the meridian scope", function(){ + I18n.locale = "de"; + + var date = new Date(2009, 3, 26, 19, 35, 44); + expect(I18n.strftime(date, "%p")).toEqual("de:PM"); + expect(I18n.strftime(date, "%P")).toEqual("de:pm"); + + date = new Date(2009, 3, 26, 7, 35, 44); + expect(I18n.strftime(date, "%p")).toEqual("de:AM"); + expect(I18n.strftime(date, "%P")).toEqual("de:am"); + }); + + it("fails to format invalid date", function(){ + var date = new Date('foo'); + expect(function() { + I18n.strftime(date, "%a"); + }).toThrow('I18n.strftime() requires a valid date object, but received an invalid date.'); + }); +}); diff --git a/spec/js/defaults.spec.js b/spec/js/defaults.spec.js new file mode 100644 index 00000000..187669f3 --- /dev/null +++ b/spec/js/defaults.spec.js @@ -0,0 +1,31 @@ +var I18n = require("../../app/assets/javascripts/i18n"); + +describe("Defaults", function(){ + beforeEach(function(){ + I18n.reset(); + }); + + it("sets the default locale", function(){ + expect(I18n.defaultLocale).toEqual("en"); + }); + + it("sets current locale", function(){ + expect(I18n.locale).toEqual("en"); + }); + + it("sets default separator", function(){ + expect(I18n.defaultSeparator).toEqual("."); + }); + + it("sets fallback", function(){ + expect(I18n.fallbacks).toEqual(false); + }); + + it("set empty translation prefix", function(){ + expect(I18n.missingTranslationPrefix).toEqual(''); + }); + + it("sets default missingBehaviour", function(){ + expect(I18n.missingBehaviour).toEqual('message'); + }); +}); diff --git a/spec/js/extend.spec.js b/spec/js/extend.spec.js new file mode 100644 index 00000000..df247cd7 --- /dev/null +++ b/spec/js/extend.spec.js @@ -0,0 +1,110 @@ +var I18n = require("../../app/assets/javascripts/i18n") + , Translations = require("./translations") +; + +describe("Extend", function () { + it("should return an object", function () { + expect(typeof I18n.extend()).toBe('object'); + }); + + it("should merge 2 objects into 1", function () { + var obj1 = { + test1: "abc" + } + , obj2 = { + test2: "xyz" + } + , expected = { + test1: "abc" + , test2: "xyz" + }; + + expect(I18n.extend(obj1,obj2)).toEqual(expected); + }); + it("should overwrite a property from obj1 with the same property of obj2", function () { + var obj1 = { + test1: "abc" + , test3: "def" + } + , obj2 = { + test2: "xyz" + , test3: "uvw" + } + , expected = { + test1: "abc" + , test2: "xyz" + , test3: "uvw" + }; + + expect(I18n.extend(obj1,obj2)).toEqual(expected); + }); + + it("should merge deeply from obj1 with the same key of obj2", function() { + var obj1 = { + test1: { + test2: "abc" + } + } + , obj2 = { + test1: { + test3: "xyz" + } + } + , expected = { + test1: { + test2: "abc" + , test3: "xyz" + } + } + + expect(I18n.extend(obj1, obj2)).toEqual(expected); + }); + + it("should correctly merge string, numberic, boolean, and null values", function() { + var obj1 = { + test1: { + test2: false + } + } + , obj2 = { + test1: { + test3: 23, + test4: 'abc', + test5: null + } + } + , expected = { + test1: { + test2: false + , test3: 23 + , test4: 'abc' + , test5: null + } + } + + expect(I18n.extend(obj1, obj2)).toEqual(expected); + }); + + it("should merge array values", function() { + var obj1 = { + array1: [1, 2] + }, + obj2 = { + array2: [1, 2], + obj3: { + array3: [1, 2], + array4: [{obj4: 1}, 2] + } + }, + expected = { + array1: [1, 2], + array2: [1, 2], + obj3: { + array3: [1, 2], + array4: [{obj4: 1}, 2] + } + } + + expect(JSON.stringify(I18n.extend(obj1, obj2))).toEqual(JSON.stringify(expected)); + }); +}); diff --git a/spec/js/interpolation.spec.js b/spec/js/interpolation.spec.js new file mode 100644 index 00000000..65cba499 --- /dev/null +++ b/spec/js/interpolation.spec.js @@ -0,0 +1,124 @@ +var I18n = require("../../app/assets/javascripts/i18n") + , Translations = require("./translations") +; + +describe("Interpolation", function(){ + var actual, expected; + + beforeEach(function(){ + I18n.reset(); + I18n.translations = Translations(); + }); + + it("performs single interpolation", function(){ + actual = I18n.t("greetings.name", {name: "John Doe"}); + expect(actual).toEqual("Hello John Doe!"); + }); + + it("performs multiple interpolations", function(){ + actual = I18n.t("profile.details", {name: "John Doe", age: 27}); + expect(actual).toEqual("John Doe is 27-years old"); + }); + + describe("Pluralization", function() { + var translation_key; + + describe("when count is passed in", function() { + describe("and translation key does contain pluralization", function() { + beforeEach(function() { + translation_key = "inbox"; + }); + + it("return translated and pluralized string", function() { + expect(I18n.t(translation_key, {count: 0})).toEqual("You have no messages"); + expect(I18n.t(translation_key, {count: 1})).toEqual("You have 1 message"); + expect(I18n.t(translation_key, {count: 5})).toEqual("You have 5 messages"); + }); + }); + describe("and translation key does NOT contain pluralization", function() { + beforeEach(function() { + translation_key = "hello"; + }); + + it("return translated string ONLY", function() { + expect(I18n.t(translation_key, {count: 0})).toEqual("Hello World!"); + expect(I18n.t(translation_key, {count: 1})).toEqual("Hello World!"); + expect(I18n.t(translation_key, {count: 5})).toEqual("Hello World!"); + }); + }); + describe("and translation key does contain pluralization with null content", function() { + beforeEach(function() { + translation_key = "sent"; + }); + + it("return empty string", function() { + expect(I18n.t(translation_key, {count: 0})).toEqual('[missing "en.sent.zero" translation]'); + expect(I18n.t(translation_key, {count: 1})).toEqual('[missing "en.sent.one" translation]'); + expect(I18n.t(translation_key, {count: 5})).toEqual('[missing "en.sent.other" translation]'); + }); + }); + }); + + describe("when count is NOT passed in", function() { + describe("and translation key does contain pluralization", function() { + beforeEach(function() { + translation_key = "inbox"; + }); + + var expected_translation_object = { + one : 'You have {{count}} message', + other : 'You have {{count}} messages', + zero : 'You have no messages' + } + + it("return translated and pluralized string", function() { + expect(I18n.t(translation_key, {not_count: 0})).toEqual(expected_translation_object); + expect(I18n.t(translation_key, {not_count: 1})).toEqual(expected_translation_object); + expect(I18n.t(translation_key, {not_count: 5})).toEqual(expected_translation_object); + }); + }); + describe("and translation key does NOT contain pluralization", function() { + beforeEach(function() { + translation_key = "hello"; + }); + + it("return translated string ONLY", function() { + expect(I18n.t(translation_key, {not_count: 0})).toEqual("Hello World!"); + expect(I18n.t(translation_key, {not_count: 1})).toEqual("Hello World!"); + expect(I18n.t(translation_key, {not_count: 5})).toEqual("Hello World!"); + }); + }); + }); + }); + + it("outputs missing placeholder message if interpolation value is missing", function(){ + actual = I18n.t("greetings.name"); + expect(actual).toEqual("Hello [missing {{name}} value]!"); + }); + + it("outputs missing placeholder message if interpolation value is null", function(){ + actual = I18n.t("greetings.name", {name: null}); + expect(actual).toEqual("Hello [missing {{name}} value]!"); + }); + + it("allows overriding the null placeholder message", function(){ + var orig = I18n.nullPlaceholder; + I18n.nullPlaceholder = function() {return "";} + actual = I18n.t("greetings.name", {name: null}); + expect(actual).toEqual("Hello !"); + I18n.nullPlaceholder = orig; + }); + + it("provides missingPlaceholder with the placeholder, message, and options object", function(){ + var orig = I18n.missingPlaceholder; + I18n.missingPlaceholder = function(placeholder, message, options) { + expect(placeholder).toEqual('{{name}}'); + expect(message).toEqual('Hello {{name}}!'); + expect(options.debugScope).toEqual('landing-page'); + return '[missing-placeholder-debug]'; + }; + actual = I18n.t("greetings.name", {debugScope: 'landing-page'}); + expect(actual).toEqual("Hello [missing-placeholder-debug]!"); + I18n.missingPlaceholder = orig; + }); +}); diff --git a/spec/js/jasmine/MIT.LICENSE b/spec/js/jasmine/MIT.LICENSE new file mode 100644 index 00000000..7c435baa --- /dev/null +++ b/spec/js/jasmine/MIT.LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2008-2011 Pivotal Labs + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/spec/js/jasmine/jasmine-html.js b/spec/js/jasmine/jasmine-html.js new file mode 100644 index 00000000..73834010 --- /dev/null +++ b/spec/js/jasmine/jasmine-html.js @@ -0,0 +1,190 @@ +jasmine.TrivialReporter = function(doc) { + this.document = doc || document; + this.suiteDivs = {}; + this.logRunningSpecs = false; +}; + +jasmine.TrivialReporter.prototype.createDom = function(type, attrs, childrenVarArgs) { + var el = document.createElement(type); + + for (var i = 2; i < arguments.length; i++) { + var child = arguments[i]; + + if (typeof child === 'string') { + el.appendChild(document.createTextNode(child)); + } else { + if (child) { el.appendChild(child); } + } + } + + for (var attr in attrs) { + if (attr == "className") { + el[attr] = attrs[attr]; + } else { + el.setAttribute(attr, attrs[attr]); + } + } + + return el; +}; + +jasmine.TrivialReporter.prototype.reportRunnerStarting = function(runner) { + var showPassed, showSkipped; + + this.outerDiv = this.createDom('div', { className: 'jasmine_reporter' }, + this.createDom('div', { className: 'banner' }, + this.createDom('div', { className: 'logo' }, + this.createDom('span', { className: 'title' }, "Jasmine"), + this.createDom('span', { className: 'version' }, runner.env.versionString())), + this.createDom('div', { className: 'options' }, + "Show ", + showPassed = this.createDom('input', { id: "__jasmine_TrivialReporter_showPassed__", type: 'checkbox' }), + this.createDom('label', { "for": "__jasmine_TrivialReporter_showPassed__" }, " passed "), + showSkipped = this.createDom('input', { id: "__jasmine_TrivialReporter_showSkipped__", type: 'checkbox' }), + this.createDom('label', { "for": "__jasmine_TrivialReporter_showSkipped__" }, " skipped") + ) + ), + + this.runnerDiv = this.createDom('div', { className: 'runner running' }, + this.createDom('a', { className: 'run_spec', href: '?' }, "run all"), + this.runnerMessageSpan = this.createDom('span', {}, "Running..."), + this.finishedAtSpan = this.createDom('span', { className: 'finished-at' }, "")) + ); + + this.document.body.appendChild(this.outerDiv); + + var suites = runner.suites(); + for (var i = 0; i < suites.length; i++) { + var suite = suites[i]; + var suiteDiv = this.createDom('div', { className: 'suite' }, + this.createDom('a', { className: 'run_spec', href: '?spec=' + encodeURIComponent(suite.getFullName()) }, "run"), + this.createDom('a', { className: 'description', href: '?spec=' + encodeURIComponent(suite.getFullName()) }, suite.description)); + this.suiteDivs[suite.id] = suiteDiv; + var parentDiv = this.outerDiv; + if (suite.parentSuite) { + parentDiv = this.suiteDivs[suite.parentSuite.id]; + } + parentDiv.appendChild(suiteDiv); + } + + this.startedAt = new Date(); + + var self = this; + showPassed.onclick = function(evt) { + if (showPassed.checked) { + self.outerDiv.className += ' show-passed'; + } else { + self.outerDiv.className = self.outerDiv.className.replace(/ show-passed/, ''); + } + }; + + showSkipped.onclick = function(evt) { + if (showSkipped.checked) { + self.outerDiv.className += ' show-skipped'; + } else { + self.outerDiv.className = self.outerDiv.className.replace(/ show-skipped/, ''); + } + }; +}; + +jasmine.TrivialReporter.prototype.reportRunnerResults = function(runner) { + var results = runner.results(); + var className = (results.failedCount > 0) ? "runner failed" : "runner passed"; + this.runnerDiv.setAttribute("class", className); + //do it twice for IE + this.runnerDiv.setAttribute("className", className); + var specs = runner.specs(); + var specCount = 0; + for (var i = 0; i < specs.length; i++) { + if (this.specFilter(specs[i])) { + specCount++; + } + } + var message = "" + specCount + " spec" + (specCount == 1 ? "" : "s" ) + ", " + results.failedCount + " failure" + ((results.failedCount == 1) ? "" : "s"); + message += " in " + ((new Date().getTime() - this.startedAt.getTime()) / 1000) + "s"; + this.runnerMessageSpan.replaceChild(this.createDom('a', { className: 'description', href: '?'}, message), this.runnerMessageSpan.firstChild); + + this.finishedAtSpan.appendChild(document.createTextNode("Finished at " + new Date().toString())); +}; + +jasmine.TrivialReporter.prototype.reportSuiteResults = function(suite) { + var results = suite.results(); + var status = results.passed() ? 'passed' : 'failed'; + if (results.totalCount === 0) { // todo: change this to check results.skipped + status = 'skipped'; + } + this.suiteDivs[suite.id].className += " " + status; +}; + +jasmine.TrivialReporter.prototype.reportSpecStarting = function(spec) { + if (this.logRunningSpecs) { + this.log('>> Jasmine Running ' + spec.suite.description + ' ' + spec.description + '...'); + } +}; + +jasmine.TrivialReporter.prototype.reportSpecResults = function(spec) { + var results = spec.results(); + var status = results.passed() ? 'passed' : 'failed'; + if (results.skipped) { + status = 'skipped'; + } + var specDiv = this.createDom('div', { className: 'spec ' + status }, + this.createDom('a', { className: 'run_spec', href: '?spec=' + encodeURIComponent(spec.getFullName()) }, "run"), + this.createDom('a', { + className: 'description', + href: '?spec=' + encodeURIComponent(spec.getFullName()), + title: spec.getFullName() + }, spec.description)); + + + var resultItems = results.getItems(); + var messagesDiv = this.createDom('div', { className: 'messages' }); + for (var i = 0; i < resultItems.length; i++) { + var result = resultItems[i]; + + if (result.type == 'log') { + messagesDiv.appendChild(this.createDom('div', {className: 'resultMessage log'}, result.toString())); + } else if (result.type == 'expect' && result.passed && !result.passed()) { + messagesDiv.appendChild(this.createDom('div', {className: 'resultMessage fail'}, result.message)); + + if (result.trace.stack) { + messagesDiv.appendChild(this.createDom('div', {className: 'stackTrace'}, result.trace.stack)); + } + } + } + + if (messagesDiv.childNodes.length > 0) { + specDiv.appendChild(messagesDiv); + } + + this.suiteDivs[spec.suite.id].appendChild(specDiv); +}; + +jasmine.TrivialReporter.prototype.log = function() { + var console = jasmine.getGlobal().console; + if (console && console.log) { + if (console.log.apply) { + console.log.apply(console, arguments); + } else { + console.log(arguments); // ie fix: console.log.apply doesn't exist on ie + } + } +}; + +jasmine.TrivialReporter.prototype.getLocation = function() { + return this.document.location; +}; + +jasmine.TrivialReporter.prototype.specFilter = function(spec) { + var paramMap = {}; + var params = this.getLocation().search.substring(1).split('&'); + for (var i = 0; i < params.length; i++) { + var p = params[i].split('='); + paramMap[decodeURIComponent(p[0])] = decodeURIComponent(p[1]); + } + + if (!paramMap.spec) { + return true; + } + return spec.getFullName().indexOf(paramMap.spec) === 0; +}; diff --git a/spec/js/jasmine/jasmine.css b/spec/js/jasmine/jasmine.css new file mode 100644 index 00000000..6583fe7c --- /dev/null +++ b/spec/js/jasmine/jasmine.css @@ -0,0 +1,166 @@ +body { + font-family: "Helvetica Neue Light", "Lucida Grande", "Calibri", "Arial", sans-serif; +} + + +.jasmine_reporter a:visited, .jasmine_reporter a { + color: #303; +} + +.jasmine_reporter a:hover, .jasmine_reporter a:active { + color: blue; +} + +.run_spec { + float:right; + padding-right: 5px; + font-size: .8em; + text-decoration: none; +} + +.jasmine_reporter { + margin: 0 5px; +} + +.banner { + color: #303; + background-color: #fef; + padding: 5px; +} + +.logo { + float: left; + font-size: 1.1em; + padding-left: 5px; +} + +.logo .version { + font-size: .6em; + padding-left: 1em; +} + +.runner.running { + background-color: yellow; +} + + +.options { + text-align: right; + font-size: .8em; +} + + + + +.suite { + border: 1px outset gray; + margin: 5px 0; + padding-left: 1em; +} + +.suite .suite { + margin: 5px; +} + +.suite.passed { + background-color: #dfd; +} + +.suite.failed { + background-color: #fdd; +} + +.spec { + margin: 5px; + padding-left: 1em; + clear: both; +} + +.spec.failed, .spec.passed, .spec.skipped { + padding-bottom: 5px; + border: 1px solid gray; +} + +.spec.failed { + background-color: #fbb; + border-color: red; +} + +.spec.passed { + background-color: #bfb; + border-color: green; +} + +.spec.skipped { + background-color: #bbb; +} + +.messages { + border-left: 1px dashed gray; + padding-left: 1em; + padding-right: 1em; +} + +.passed { + background-color: #cfc; + display: none; +} + +.failed { + background-color: #fbb; +} + +.skipped { + color: #777; + background-color: #eee; + display: none; +} + + +/*.resultMessage {*/ + /*white-space: pre;*/ +/*}*/ + +.resultMessage span.result { + display: block; + line-height: 2em; + color: black; +} + +.resultMessage .mismatch { + color: black; +} + +.stackTrace { + white-space: pre; + font-size: .8em; + margin-left: 10px; + max-height: 5em; + overflow: auto; + border: 1px inset red; + padding: 1em; + background: #eef; +} + +.finished-at { + padding-left: 1em; + font-size: .6em; +} + +.show-passed .passed, +.show-skipped .skipped { + display: block; +} + + +#jasmine_content { + position:fixed; + right: 100%; +} + +.runner { + border: 1px solid gray; + display: block; + margin: 5px 0; + padding: 2px 0 2px 10px; +} diff --git a/spec/js/jasmine/jasmine.js b/spec/js/jasmine/jasmine.js new file mode 100644 index 00000000..c3d2dc7d --- /dev/null +++ b/spec/js/jasmine/jasmine.js @@ -0,0 +1,2476 @@ +var isCommonJS = typeof window == "undefined"; + +/** + * Top level namespace for Jasmine, a lightweight JavaScript BDD/spec/testing framework. + * + * @namespace + */ +var jasmine = {}; +if (isCommonJS) exports.jasmine = jasmine; +/** + * @private + */ +jasmine.unimplementedMethod_ = function() { + throw new Error("unimplemented method"); +}; + +/** + * Use jasmine.undefined instead of undefined, since undefined is just + * a plain old variable and may be redefined by somebody else. + * + * @private + */ +jasmine.undefined = jasmine.___undefined___; + +/** + * Show diagnostic messages in the console if set to true + * + */ +jasmine.VERBOSE = false; + +/** + * Default interval in milliseconds for event loop yields (e.g. to allow network activity or to refresh the screen with the HTML-based runner). Small values here may result in slow test running. Zero means no updates until all tests have completed. + * + */ +jasmine.DEFAULT_UPDATE_INTERVAL = 250; + +/** + * Default timeout interval in milliseconds for waitsFor() blocks. + */ +jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000; + +jasmine.getGlobal = function() { + function getGlobal() { + return this; + } + + return getGlobal(); +}; + +/** + * Allows for bound functions to be compared. Internal use only. + * + * @ignore + * @private + * @param base {Object} bound 'this' for the function + * @param name {Function} function to find + */ +jasmine.bindOriginal_ = function(base, name) { + var original = base[name]; + if (original.apply) { + return function() { + return original.apply(base, arguments); + }; + } else { + // IE support + return jasmine.getGlobal()[name]; + } +}; + +jasmine.setTimeout = jasmine.bindOriginal_(jasmine.getGlobal(), 'setTimeout'); +jasmine.clearTimeout = jasmine.bindOriginal_(jasmine.getGlobal(), 'clearTimeout'); +jasmine.setInterval = jasmine.bindOriginal_(jasmine.getGlobal(), 'setInterval'); +jasmine.clearInterval = jasmine.bindOriginal_(jasmine.getGlobal(), 'clearInterval'); + +jasmine.MessageResult = function(values) { + this.type = 'log'; + this.values = values; + this.trace = new Error(); // todo: test better +}; + +jasmine.MessageResult.prototype.toString = function() { + var text = ""; + for (var i = 0; i < this.values.length; i++) { + if (i > 0) text += " "; + if (jasmine.isString_(this.values[i])) { + text += this.values[i]; + } else { + text += jasmine.pp(this.values[i]); + } + } + return text; +}; + +jasmine.ExpectationResult = function(params) { + this.type = 'expect'; + this.matcherName = params.matcherName; + this.passed_ = params.passed; + this.expected = params.expected; + this.actual = params.actual; + this.message = this.passed_ ? 'Passed.' : params.message; + + var trace = (params.trace || new Error(this.message)); + this.trace = this.passed_ ? '' : trace; +}; + +jasmine.ExpectationResult.prototype.toString = function () { + return this.message; +}; + +jasmine.ExpectationResult.prototype.passed = function () { + return this.passed_; +}; + +/** + * Getter for the Jasmine environment. Ensures one gets created + */ +jasmine.getEnv = function() { + var env = jasmine.currentEnv_ = jasmine.currentEnv_ || new jasmine.Env(); + return env; +}; + +/** + * @ignore + * @private + * @param value + * @returns {Boolean} + */ +jasmine.isArray_ = function(value) { + return jasmine.isA_("Array", value); +}; + +/** + * @ignore + * @private + * @param value + * @returns {Boolean} + */ +jasmine.isString_ = function(value) { + return jasmine.isA_("String", value); +}; + +/** + * @ignore + * @private + * @param value + * @returns {Boolean} + */ +jasmine.isNumber_ = function(value) { + return jasmine.isA_("Number", value); +}; + +/** + * @ignore + * @private + * @param {String} typeName + * @param value + * @returns {Boolean} + */ +jasmine.isA_ = function(typeName, value) { + return Object.prototype.toString.apply(value) === '[object ' + typeName + ']'; +}; + +/** + * Pretty printer for expecations. Takes any object and turns it into a human-readable string. + * + * @param value {Object} an object to be outputted + * @returns {String} + */ +jasmine.pp = function(value) { + var stringPrettyPrinter = new jasmine.StringPrettyPrinter(); + stringPrettyPrinter.format(value); + return stringPrettyPrinter.string; +}; + +/** + * Returns true if the object is a DOM Node. + * + * @param {Object} obj object to check + * @returns {Boolean} + */ +jasmine.isDomNode = function(obj) { + return obj.nodeType > 0; +}; + +/** + * Returns a matchable 'generic' object of the class type. For use in expecations of type when values don't matter. + * + * @example + * // don't care about which function is passed in, as long as it's a function + * expect(mySpy).toHaveBeenCalledWith(jasmine.any(Function)); + * + * @param {Class} clazz + * @returns matchable object of the type clazz + */ +jasmine.any = function(clazz) { + return new jasmine.Matchers.Any(clazz); +}; + +/** + * Jasmine Spies are test doubles that can act as stubs, spies, fakes or when used in an expecation, mocks. + * + * Spies should be created in test setup, before expectations. They can then be checked, using the standard Jasmine + * expectation syntax. Spies can be checked if they were called or not and what the calling params were. + * + * A Spy has the following fields: wasCalled, callCount, mostRecentCall, and argsForCall (see docs). + * + * Spies are torn down at the end of every spec. + * + * Note: Do not call new jasmine.Spy() directly - a spy must be created using spyOn, jasmine.createSpy or jasmine.createSpyObj. + * + * @example + * // a stub + * var myStub = jasmine.createSpy('myStub'); // can be used anywhere + * + * // spy example + * var foo = { + * not: function(bool) { return !bool; } + * } + * + * // actual foo.not will not be called, execution stops + * spyOn(foo, 'not'); + + // foo.not spied upon, execution will continue to implementation + * spyOn(foo, 'not').andCallThrough(); + * + * // fake example + * var foo = { + * not: function(bool) { return !bool; } + * } + * + * // foo.not(val) will return val + * spyOn(foo, 'not').andCallFake(function(value) {return value;}); + * + * // mock example + * foo.not(7 == 7); + * expect(foo.not).toHaveBeenCalled(); + * expect(foo.not).toHaveBeenCalledWith(true); + * + * @constructor + * @see spyOn, jasmine.createSpy, jasmine.createSpyObj + * @param {String} name + */ +jasmine.Spy = function(name) { + /** + * The name of the spy, if provided. + */ + this.identity = name || 'unknown'; + /** + * Is this Object a spy? + */ + this.isSpy = true; + /** + * The actual function this spy stubs. + */ + this.plan = function() { + }; + /** + * Tracking of the most recent call to the spy. + * @example + * var mySpy = jasmine.createSpy('foo'); + * mySpy(1, 2); + * mySpy.mostRecentCall.args = [1, 2]; + */ + this.mostRecentCall = {}; + + /** + * Holds arguments for each call to the spy, indexed by call count + * @example + * var mySpy = jasmine.createSpy('foo'); + * mySpy(1, 2); + * mySpy(7, 8); + * mySpy.mostRecentCall.args = [7, 8]; + * mySpy.argsForCall[0] = [1, 2]; + * mySpy.argsForCall[1] = [7, 8]; + */ + this.argsForCall = []; + this.calls = []; +}; + +/** + * Tells a spy to call through to the actual implemenatation. + * + * @example + * var foo = { + * bar: function() { // do some stuff } + * } + * + * // defining a spy on an existing property: foo.bar + * spyOn(foo, 'bar').andCallThrough(); + */ +jasmine.Spy.prototype.andCallThrough = function() { + this.plan = this.originalValue; + return this; +}; + +/** + * For setting the return value of a spy. + * + * @example + * // defining a spy from scratch: foo() returns 'baz' + * var foo = jasmine.createSpy('spy on foo').andReturn('baz'); + * + * // defining a spy on an existing property: foo.bar() returns 'baz' + * spyOn(foo, 'bar').andReturn('baz'); + * + * @param {Object} value + */ +jasmine.Spy.prototype.andReturn = function(value) { + this.plan = function() { + return value; + }; + return this; +}; + +/** + * For throwing an exception when a spy is called. + * + * @example + * // defining a spy from scratch: foo() throws an exception w/ message 'ouch' + * var foo = jasmine.createSpy('spy on foo').andThrow('baz'); + * + * // defining a spy on an existing property: foo.bar() throws an exception w/ message 'ouch' + * spyOn(foo, 'bar').andThrow('baz'); + * + * @param {String} exceptionMsg + */ +jasmine.Spy.prototype.andThrow = function(exceptionMsg) { + this.plan = function() { + throw exceptionMsg; + }; + return this; +}; + +/** + * Calls an alternate implementation when a spy is called. + * + * @example + * var baz = function() { + * // do some stuff, return something + * } + * // defining a spy from scratch: foo() calls the function baz + * var foo = jasmine.createSpy('spy on foo').andCall(baz); + * + * // defining a spy on an existing property: foo.bar() calls an anonymnous function + * spyOn(foo, 'bar').andCall(function() { return 'baz';} ); + * + * @param {Function} fakeFunc + */ +jasmine.Spy.prototype.andCallFake = function(fakeFunc) { + this.plan = fakeFunc; + return this; +}; + +/** + * Resets all of a spy's the tracking variables so that it can be used again. + * + * @example + * spyOn(foo, 'bar'); + * + * foo.bar(); + * + * expect(foo.bar.callCount).toEqual(1); + * + * foo.bar.reset(); + * + * expect(foo.bar.callCount).toEqual(0); + */ +jasmine.Spy.prototype.reset = function() { + this.wasCalled = false; + this.callCount = 0; + this.argsForCall = []; + this.calls = []; + this.mostRecentCall = {}; +}; + +jasmine.createSpy = function(name) { + + var spyObj = function() { + spyObj.wasCalled = true; + spyObj.callCount++; + var args = jasmine.util.argsToArray(arguments); + spyObj.mostRecentCall.object = this; + spyObj.mostRecentCall.args = args; + spyObj.argsForCall.push(args); + spyObj.calls.push({object: this, args: args}); + return spyObj.plan.apply(this, arguments); + }; + + var spy = new jasmine.Spy(name); + + for (var prop in spy) { + spyObj[prop] = spy[prop]; + } + + spyObj.reset(); + + return spyObj; +}; + +/** + * Determines whether an object is a spy. + * + * @param {jasmine.Spy|Object} putativeSpy + * @returns {Boolean} + */ +jasmine.isSpy = function(putativeSpy) { + return putativeSpy && putativeSpy.isSpy; +}; + +/** + * Creates a more complicated spy: an Object that has every property a function that is a spy. Used for stubbing something + * large in one call. + * + * @param {String} baseName name of spy class + * @param {Array} methodNames array of names of methods to make spies + */ +jasmine.createSpyObj = function(baseName, methodNames) { + if (!jasmine.isArray_(methodNames) || methodNames.length === 0) { + throw new Error('createSpyObj requires a non-empty array of method names to create spies for'); + } + var obj = {}; + for (var i = 0; i < methodNames.length; i++) { + obj[methodNames[i]] = jasmine.createSpy(baseName + '.' + methodNames[i]); + } + return obj; +}; + +/** + * All parameters are pretty-printed and concatenated together, then written to the current spec's output. + * + * Be careful not to leave calls to jasmine.log in production code. + */ +jasmine.log = function() { + var spec = jasmine.getEnv().currentSpec; + spec.log.apply(spec, arguments); +}; + +/** + * Function that installs a spy on an existing object's method name. Used within a Spec to create a spy. + * + * @example + * // spy example + * var foo = { + * not: function(bool) { return !bool; } + * } + * spyOn(foo, 'not'); // actual foo.not will not be called, execution stops + * + * @see jasmine.createSpy + * @param obj + * @param methodName + * @returns a Jasmine spy that can be chained with all spy methods + */ +var spyOn = function(obj, methodName) { + return jasmine.getEnv().currentSpec.spyOn(obj, methodName); +}; +if (isCommonJS) exports.spyOn = spyOn; + +/** + * Creates a Jasmine spec that will be added to the current suite. + * + * // TODO: pending tests + * + * @example + * it('should be true', function() { + * expect(true).toEqual(true); + * }); + * + * @param {String} desc description of this specification + * @param {Function} func defines the preconditions and expectations of the spec + */ +var it = function(desc, func) { + return jasmine.getEnv().it(desc, func); +}; +if (isCommonJS) exports.it = it; + +/** + * Creates a disabled Jasmine spec. + * + * A convenience method that allows existing specs to be disabled temporarily during development. + * + * @param {String} desc description of this specification + * @param {Function} func defines the preconditions and expectations of the spec + */ +var xit = function(desc, func) { + return jasmine.getEnv().xit(desc, func); +}; +if (isCommonJS) exports.xit = xit; + +/** + * Starts a chain for a Jasmine expectation. + * + * It is passed an Object that is the actual value and should chain to one of the many + * jasmine.Matchers functions. + * + * @param {Object} actual Actual value to test against and expected value + */ +var expect = function(actual) { + return jasmine.getEnv().currentSpec.expect(actual); +}; +if (isCommonJS) exports.expect = expect; + +/** + * Defines part of a jasmine spec. Used in cominbination with waits or waitsFor in asynchrnous specs. + * + * @param {Function} func Function that defines part of a jasmine spec. + */ +var runs = function(func) { + jasmine.getEnv().currentSpec.runs(func); +}; +if (isCommonJS) exports.runs = runs; + +/** + * Waits a fixed time period before moving to the next block. + * + * @deprecated Use waitsFor() instead + * @param {Number} timeout milliseconds to wait + */ +var waits = function(timeout) { + jasmine.getEnv().currentSpec.waits(timeout); +}; +if (isCommonJS) exports.waits = waits; + +/** + * Waits for the latchFunction to return true before proceeding to the next block. + * + * @param {Function} latchFunction + * @param {String} optional_timeoutMessage + * @param {Number} optional_timeout + */ +var waitsFor = function(latchFunction, optional_timeoutMessage, optional_timeout) { + jasmine.getEnv().currentSpec.waitsFor.apply(jasmine.getEnv().currentSpec, arguments); +}; +if (isCommonJS) exports.waitsFor = waitsFor; + +/** + * A function that is called before each spec in a suite. + * + * Used for spec setup, including validating assumptions. + * + * @param {Function} beforeEachFunction + */ +var beforeEach = function(beforeEachFunction) { + jasmine.getEnv().beforeEach(beforeEachFunction); +}; +if (isCommonJS) exports.beforeEach = beforeEach; + +/** + * A function that is called after each spec in a suite. + * + * Used for restoring any state that is hijacked during spec execution. + * + * @param {Function} afterEachFunction + */ +var afterEach = function(afterEachFunction) { + jasmine.getEnv().afterEach(afterEachFunction); +}; +if (isCommonJS) exports.afterEach = afterEach; + +/** + * Defines a suite of specifications. + * + * Stores the description and all defined specs in the Jasmine environment as one suite of specs. Variables declared + * are accessible by calls to beforeEach, it, and afterEach. Describe blocks can be nested, allowing for specialization + * of setup in some tests. + * + * @example + * // TODO: a simple suite + * + * // TODO: a simple suite with a nested describe block + * + * @param {String} description A string, usually the class under test. + * @param {Function} specDefinitions function that defines several specs. + */ +var describe = function(description, specDefinitions) { + return jasmine.getEnv().describe(description, specDefinitions); +}; +if (isCommonJS) exports.describe = describe; + +/** + * Disables a suite of specifications. Used to disable some suites in a file, or files, temporarily during development. + * + * @param {String} description A string, usually the class under test. + * @param {Function} specDefinitions function that defines several specs. + */ +var xdescribe = function(description, specDefinitions) { + return jasmine.getEnv().xdescribe(description, specDefinitions); +}; +if (isCommonJS) exports.xdescribe = xdescribe; + + +// Provide the XMLHttpRequest class for IE 5.x-6.x: +jasmine.XmlHttpRequest = (typeof XMLHttpRequest == "undefined") ? function() { + function tryIt(f) { + try { + return f(); + } catch(e) { + } + return null; + } + + var xhr = tryIt(function() { + return new ActiveXObject("Msxml2.XMLHTTP.6.0"); + }) || + tryIt(function() { + return new ActiveXObject("Msxml2.XMLHTTP.3.0"); + }) || + tryIt(function() { + return new ActiveXObject("Msxml2.XMLHTTP"); + }) || + tryIt(function() { + return new ActiveXObject("Microsoft.XMLHTTP"); + }); + + if (!xhr) throw new Error("This browser does not support XMLHttpRequest."); + + return xhr; +} : XMLHttpRequest; +/** + * @namespace + */ +jasmine.util = {}; + +/** + * Declare that a child class inherit it's prototype from the parent class. + * + * @private + * @param {Function} childClass + * @param {Function} parentClass + */ +jasmine.util.inherit = function(childClass, parentClass) { + /** + * @private + */ + var subclass = function() { + }; + subclass.prototype = parentClass.prototype; + childClass.prototype = new subclass(); +}; + +jasmine.util.formatException = function(e) { + var lineNumber; + if (e.line) { + lineNumber = e.line; + } + else if (e.lineNumber) { + lineNumber = e.lineNumber; + } + + var file; + + if (e.sourceURL) { + file = e.sourceURL; + } + else if (e.fileName) { + file = e.fileName; + } + + var message = (e.name && e.message) ? (e.name + ': ' + e.message) : e.toString(); + + if (file && lineNumber) { + message += ' in ' + file + ' (line ' + lineNumber + ')'; + } + + return message; +}; + +jasmine.util.htmlEscape = function(str) { + if (!str) return str; + return str.replace(/&/g, '&') + .replace(//g, '>'); +}; + +jasmine.util.argsToArray = function(args) { + var arrayOfArgs = []; + for (var i = 0; i < args.length; i++) arrayOfArgs.push(args[i]); + return arrayOfArgs; +}; + +jasmine.util.extend = function(destination, source) { + for (var property in source) destination[property] = source[property]; + return destination; +}; + +/** + * Environment for Jasmine + * + * @constructor + */ +jasmine.Env = function() { + this.currentSpec = null; + this.currentSuite = null; + this.currentRunner_ = new jasmine.Runner(this); + + this.reporter = new jasmine.MultiReporter(); + + this.updateInterval = jasmine.DEFAULT_UPDATE_INTERVAL; + this.defaultTimeoutInterval = jasmine.DEFAULT_TIMEOUT_INTERVAL; + this.lastUpdate = 0; + this.specFilter = function() { + return true; + }; + + this.nextSpecId_ = 0; + this.nextSuiteId_ = 0; + this.equalityTesters_ = []; + + // wrap matchers + this.matchersClass = function() { + jasmine.Matchers.apply(this, arguments); + }; + jasmine.util.inherit(this.matchersClass, jasmine.Matchers); + + jasmine.Matchers.wrapInto_(jasmine.Matchers.prototype, this.matchersClass); +}; + + +jasmine.Env.prototype.setTimeout = jasmine.setTimeout; +jasmine.Env.prototype.clearTimeout = jasmine.clearTimeout; +jasmine.Env.prototype.setInterval = jasmine.setInterval; +jasmine.Env.prototype.clearInterval = jasmine.clearInterval; + +/** + * @returns an object containing jasmine version build info, if set. + */ +jasmine.Env.prototype.version = function () { + if (jasmine.version_) { + return jasmine.version_; + } else { + throw new Error('Version not set'); + } +}; + +/** + * @returns string containing jasmine version build info, if set. + */ +jasmine.Env.prototype.versionString = function() { + if (!jasmine.version_) { + return "version unknown"; + } + + var version = this.version(); + var versionString = version.major + "." + version.minor + "." + version.build; + if (version.release_candidate) { + versionString += ".rc" + version.release_candidate; + } + versionString += " revision " + version.revision; + return versionString; +}; + +/** + * @returns a sequential integer starting at 0 + */ +jasmine.Env.prototype.nextSpecId = function () { + return this.nextSpecId_++; +}; + +/** + * @returns a sequential integer starting at 0 + */ +jasmine.Env.prototype.nextSuiteId = function () { + return this.nextSuiteId_++; +}; + +/** + * Register a reporter to receive status updates from Jasmine. + * @param {jasmine.Reporter} reporter An object which will receive status updates. + */ +jasmine.Env.prototype.addReporter = function(reporter) { + this.reporter.addReporter(reporter); +}; + +jasmine.Env.prototype.execute = function() { + this.currentRunner_.execute(); +}; + +jasmine.Env.prototype.describe = function(description, specDefinitions) { + var suite = new jasmine.Suite(this, description, specDefinitions, this.currentSuite); + + var parentSuite = this.currentSuite; + if (parentSuite) { + parentSuite.add(suite); + } else { + this.currentRunner_.add(suite); + } + + this.currentSuite = suite; + + var declarationError = null; + try { + specDefinitions.call(suite); + } catch(e) { + declarationError = e; + } + + if (declarationError) { + this.it("encountered a declaration exception", function() { + throw declarationError; + }); + } + + this.currentSuite = parentSuite; + + return suite; +}; + +jasmine.Env.prototype.beforeEach = function(beforeEachFunction) { + if (this.currentSuite) { + this.currentSuite.beforeEach(beforeEachFunction); + } else { + this.currentRunner_.beforeEach(beforeEachFunction); + } +}; + +jasmine.Env.prototype.currentRunner = function () { + return this.currentRunner_; +}; + +jasmine.Env.prototype.afterEach = function(afterEachFunction) { + if (this.currentSuite) { + this.currentSuite.afterEach(afterEachFunction); + } else { + this.currentRunner_.afterEach(afterEachFunction); + } + +}; + +jasmine.Env.prototype.xdescribe = function(desc, specDefinitions) { + return { + execute: function() { + } + }; +}; + +jasmine.Env.prototype.it = function(description, func) { + var spec = new jasmine.Spec(this, this.currentSuite, description); + this.currentSuite.add(spec); + this.currentSpec = spec; + + if (func) { + spec.runs(func); + } + + return spec; +}; + +jasmine.Env.prototype.xit = function(desc, func) { + return { + id: this.nextSpecId(), + runs: function() { + } + }; +}; + +jasmine.Env.prototype.compareObjects_ = function(a, b, mismatchKeys, mismatchValues) { + if (a.__Jasmine_been_here_before__ === b && b.__Jasmine_been_here_before__ === a) { + return true; + } + + a.__Jasmine_been_here_before__ = b; + b.__Jasmine_been_here_before__ = a; + + var hasKey = function(obj, keyName) { + return obj !== null && obj[keyName] !== jasmine.undefined; + }; + + for (var property in b) { + if (!hasKey(a, property) && hasKey(b, property)) { + mismatchKeys.push("expected has key '" + property + "', but missing from actual."); + } + } + for (property in a) { + if (!hasKey(b, property) && hasKey(a, property)) { + mismatchKeys.push("expected missing key '" + property + "', but present in actual."); + } + } + for (property in b) { + if (property == '__Jasmine_been_here_before__') continue; + if (!this.equals_(a[property], b[property], mismatchKeys, mismatchValues)) { + mismatchValues.push("'" + property + "' was '" + (b[property] ? jasmine.util.htmlEscape(b[property].toString()) : b[property]) + "' in expected, but was '" + (a[property] ? jasmine.util.htmlEscape(a[property].toString()) : a[property]) + "' in actual."); + } + } + + if (jasmine.isArray_(a) && jasmine.isArray_(b) && a.length != b.length) { + mismatchValues.push("arrays were not the same length"); + } + + delete a.__Jasmine_been_here_before__; + delete b.__Jasmine_been_here_before__; + return (mismatchKeys.length === 0 && mismatchValues.length === 0); +}; + +jasmine.Env.prototype.equals_ = function(a, b, mismatchKeys, mismatchValues) { + mismatchKeys = mismatchKeys || []; + mismatchValues = mismatchValues || []; + + for (var i = 0; i < this.equalityTesters_.length; i++) { + var equalityTester = this.equalityTesters_[i]; + var result = equalityTester(a, b, this, mismatchKeys, mismatchValues); + if (result !== jasmine.undefined) return result; + } + + if (a === b) return true; + + if (a === jasmine.undefined || a === null || b === jasmine.undefined || b === null) { + return (a == jasmine.undefined && b == jasmine.undefined); + } + + if (jasmine.isDomNode(a) && jasmine.isDomNode(b)) { + return a === b; + } + + if (a instanceof Date && b instanceof Date) { + return a.getTime() == b.getTime(); + } + + if (a instanceof jasmine.Matchers.Any) { + return a.matches(b); + } + + if (b instanceof jasmine.Matchers.Any) { + return b.matches(a); + } + + if (jasmine.isString_(a) && jasmine.isString_(b)) { + return (a == b); + } + + if (jasmine.isNumber_(a) && jasmine.isNumber_(b)) { + return (a == b); + } + + if (typeof a === "object" && typeof b === "object") { + return this.compareObjects_(a, b, mismatchKeys, mismatchValues); + } + + //Straight check + return (a === b); +}; + +jasmine.Env.prototype.contains_ = function(haystack, needle) { + if (jasmine.isArray_(haystack)) { + for (var i = 0; i < haystack.length; i++) { + if (this.equals_(haystack[i], needle)) return true; + } + return false; + } + return haystack.indexOf(needle) >= 0; +}; + +jasmine.Env.prototype.addEqualityTester = function(equalityTester) { + this.equalityTesters_.push(equalityTester); +}; +/** No-op base class for Jasmine reporters. + * + * @constructor + */ +jasmine.Reporter = function() { +}; + +//noinspection JSUnusedLocalSymbols +jasmine.Reporter.prototype.reportRunnerStarting = function(runner) { +}; + +//noinspection JSUnusedLocalSymbols +jasmine.Reporter.prototype.reportRunnerResults = function(runner) { +}; + +//noinspection JSUnusedLocalSymbols +jasmine.Reporter.prototype.reportSuiteResults = function(suite) { +}; + +//noinspection JSUnusedLocalSymbols +jasmine.Reporter.prototype.reportSpecStarting = function(spec) { +}; + +//noinspection JSUnusedLocalSymbols +jasmine.Reporter.prototype.reportSpecResults = function(spec) { +}; + +//noinspection JSUnusedLocalSymbols +jasmine.Reporter.prototype.log = function(str) { +}; + +/** + * Blocks are functions with executable code that make up a spec. + * + * @constructor + * @param {jasmine.Env} env + * @param {Function} func + * @param {jasmine.Spec} spec + */ +jasmine.Block = function(env, func, spec) { + this.env = env; + this.func = func; + this.spec = spec; +}; + +jasmine.Block.prototype.execute = function(onComplete) { + try { + this.func.apply(this.spec); + } catch (e) { + this.spec.fail(e); + } + onComplete(); +}; +/** JavaScript API reporter. + * + * @constructor + */ +jasmine.JsApiReporter = function() { + this.started = false; + this.finished = false; + this.suites_ = []; + this.results_ = {}; +}; + +jasmine.JsApiReporter.prototype.reportRunnerStarting = function(runner) { + this.started = true; + var suites = runner.topLevelSuites(); + for (var i = 0; i < suites.length; i++) { + var suite = suites[i]; + this.suites_.push(this.summarize_(suite)); + } +}; + +jasmine.JsApiReporter.prototype.suites = function() { + return this.suites_; +}; + +jasmine.JsApiReporter.prototype.summarize_ = function(suiteOrSpec) { + var isSuite = suiteOrSpec instanceof jasmine.Suite; + var summary = { + id: suiteOrSpec.id, + name: suiteOrSpec.description, + type: isSuite ? 'suite' : 'spec', + children: [] + }; + + if (isSuite) { + var children = suiteOrSpec.children(); + for (var i = 0; i < children.length; i++) { + summary.children.push(this.summarize_(children[i])); + } + } + return summary; +}; + +jasmine.JsApiReporter.prototype.results = function() { + return this.results_; +}; + +jasmine.JsApiReporter.prototype.resultsForSpec = function(specId) { + return this.results_[specId]; +}; + +//noinspection JSUnusedLocalSymbols +jasmine.JsApiReporter.prototype.reportRunnerResults = function(runner) { + this.finished = true; +}; + +//noinspection JSUnusedLocalSymbols +jasmine.JsApiReporter.prototype.reportSuiteResults = function(suite) { +}; + +//noinspection JSUnusedLocalSymbols +jasmine.JsApiReporter.prototype.reportSpecResults = function(spec) { + this.results_[spec.id] = { + messages: spec.results().getItems(), + result: spec.results().failedCount > 0 ? "failed" : "passed" + }; +}; + +//noinspection JSUnusedLocalSymbols +jasmine.JsApiReporter.prototype.log = function(str) { +}; + +jasmine.JsApiReporter.prototype.resultsForSpecs = function(specIds){ + var results = {}; + for (var i = 0; i < specIds.length; i++) { + var specId = specIds[i]; + results[specId] = this.summarizeResult_(this.results_[specId]); + } + return results; +}; + +jasmine.JsApiReporter.prototype.summarizeResult_ = function(result){ + var summaryMessages = []; + var messagesLength = result.messages.length; + for (var messageIndex = 0; messageIndex < messagesLength; messageIndex++) { + var resultMessage = result.messages[messageIndex]; + summaryMessages.push({ + text: resultMessage.type == 'log' ? resultMessage.toString() : jasmine.undefined, + passed: resultMessage.passed ? resultMessage.passed() : true, + type: resultMessage.type, + message: resultMessage.message, + trace: { + stack: resultMessage.passed && !resultMessage.passed() ? resultMessage.trace.stack : jasmine.undefined + } + }); + } + + return { + result : result.result, + messages : summaryMessages + }; +}; + +/** + * @constructor + * @param {jasmine.Env} env + * @param actual + * @param {jasmine.Spec} spec + */ +jasmine.Matchers = function(env, actual, spec, opt_isNot) { + this.env = env; + this.actual = actual; + this.spec = spec; + this.isNot = opt_isNot || false; + this.reportWasCalled_ = false; +}; + +// todo: @deprecated as of Jasmine 0.11, remove soon [xw] +jasmine.Matchers.pp = function(str) { + throw new Error("jasmine.Matchers.pp() is no longer supported, please use jasmine.pp() instead!"); +}; + +// todo: @deprecated Deprecated as of Jasmine 0.10. Rewrite your custom matchers to return true or false. [xw] +jasmine.Matchers.prototype.report = function(result, failing_message, details) { + throw new Error("As of jasmine 0.11, custom matchers must be implemented differently -- please see jasmine docs"); +}; + +jasmine.Matchers.wrapInto_ = function(prototype, matchersClass) { + for (var methodName in prototype) { + if (methodName == 'report') continue; + var orig = prototype[methodName]; + matchersClass.prototype[methodName] = jasmine.Matchers.matcherFn_(methodName, orig); + } +}; + +jasmine.Matchers.matcherFn_ = function(matcherName, matcherFunction) { + return function() { + var matcherArgs = jasmine.util.argsToArray(arguments); + var result = matcherFunction.apply(this, arguments); + + if (this.isNot) { + result = !result; + } + + if (this.reportWasCalled_) return result; + + var message; + if (!result) { + if (this.message) { + message = this.message.apply(this, arguments); + if (jasmine.isArray_(message)) { + message = message[this.isNot ? 1 : 0]; + } + } else { + var englishyPredicate = matcherName.replace(/[A-Z]/g, function(s) { return ' ' + s.toLowerCase(); }); + message = "Expected " + jasmine.pp(this.actual) + (this.isNot ? " not " : " ") + englishyPredicate; + if (matcherArgs.length > 0) { + for (var i = 0; i < matcherArgs.length; i++) { + if (i > 0) message += ","; + message += " " + jasmine.pp(matcherArgs[i]); + } + } + message += "."; + } + } + var expectationResult = new jasmine.ExpectationResult({ + matcherName: matcherName, + passed: result, + expected: matcherArgs.length > 1 ? matcherArgs : matcherArgs[0], + actual: this.actual, + message: message + }); + this.spec.addMatcherResult(expectationResult); + return jasmine.undefined; + }; +}; + + + + +/** + * toBe: compares the actual to the expected using === + * @param expected + */ +jasmine.Matchers.prototype.toBe = function(expected) { + return this.actual === expected; +}; + +/** + * toNotBe: compares the actual to the expected using !== + * @param expected + * @deprecated as of 1.0. Use not.toBe() instead. + */ +jasmine.Matchers.prototype.toNotBe = function(expected) { + return this.actual !== expected; +}; + +/** + * toEqual: compares the actual to the expected using common sense equality. Handles Objects, Arrays, etc. + * + * @param expected + */ +jasmine.Matchers.prototype.toEqual = function(expected) { + return this.env.equals_(this.actual, expected); +}; + +/** + * toNotEqual: compares the actual to the expected using the ! of jasmine.Matchers.toEqual + * @param expected + * @deprecated as of 1.0. Use not.toNotEqual() instead. + */ +jasmine.Matchers.prototype.toNotEqual = function(expected) { + return !this.env.equals_(this.actual, expected); +}; + +/** + * Matcher that compares the actual to the expected using a regular expression. Constructs a RegExp, so takes + * a pattern or a String. + * + * @param expected + */ +jasmine.Matchers.prototype.toMatch = function(expected) { + return new RegExp(expected).test(this.actual); +}; + +/** + * Matcher that compares the actual to the expected using the boolean inverse of jasmine.Matchers.toMatch + * @param expected + * @deprecated as of 1.0. Use not.toMatch() instead. + */ +jasmine.Matchers.prototype.toNotMatch = function(expected) { + return !(new RegExp(expected).test(this.actual)); +}; + +/** + * Matcher that compares the actual to jasmine.undefined. + */ +jasmine.Matchers.prototype.toBeDefined = function() { + return (this.actual !== jasmine.undefined); +}; + +/** + * Matcher that compares the actual to jasmine.undefined. + */ +jasmine.Matchers.prototype.toBeUndefined = function() { + return (this.actual === jasmine.undefined); +}; + +/** + * Matcher that compares the actual to null. + */ +jasmine.Matchers.prototype.toBeNull = function() { + return (this.actual === null); +}; + +/** + * Matcher that boolean not-nots the actual. + */ +jasmine.Matchers.prototype.toBeTruthy = function() { + return !!this.actual; +}; + + +/** + * Matcher that boolean nots the actual. + */ +jasmine.Matchers.prototype.toBeFalsy = function() { + return !this.actual; +}; + + +/** + * Matcher that checks to see if the actual, a Jasmine spy, was called. + */ +jasmine.Matchers.prototype.toHaveBeenCalled = function() { + if (arguments.length > 0) { + throw new Error('toHaveBeenCalled does not take arguments, use toHaveBeenCalledWith'); + } + + if (!jasmine.isSpy(this.actual)) { + throw new Error('Expected a spy, but got ' + jasmine.pp(this.actual) + '.'); + } + + this.message = function() { + return [ + "Expected spy " + this.actual.identity + " to have been called.", + "Expected spy " + this.actual.identity + " not to have been called." + ]; + }; + + return this.actual.wasCalled; +}; + +/** @deprecated Use expect(xxx).toHaveBeenCalled() instead */ +jasmine.Matchers.prototype.wasCalled = jasmine.Matchers.prototype.toHaveBeenCalled; + +/** + * Matcher that checks to see if the actual, a Jasmine spy, was not called. + * + * @deprecated Use expect(xxx).not.toHaveBeenCalled() instead + */ +jasmine.Matchers.prototype.wasNotCalled = function() { + if (arguments.length > 0) { + throw new Error('wasNotCalled does not take arguments'); + } + + if (!jasmine.isSpy(this.actual)) { + throw new Error('Expected a spy, but got ' + jasmine.pp(this.actual) + '.'); + } + + this.message = function() { + return [ + "Expected spy " + this.actual.identity + " to not have been called.", + "Expected spy " + this.actual.identity + " to have been called." + ]; + }; + + return !this.actual.wasCalled; +}; + +/** + * Matcher that checks to see if the actual, a Jasmine spy, was called with a set of parameters. + * + * @example + * + */ +jasmine.Matchers.prototype.toHaveBeenCalledWith = function() { + var expectedArgs = jasmine.util.argsToArray(arguments); + if (!jasmine.isSpy(this.actual)) { + throw new Error('Expected a spy, but got ' + jasmine.pp(this.actual) + '.'); + } + this.message = function() { + if (this.actual.callCount === 0) { + // todo: what should the failure message for .not.toHaveBeenCalledWith() be? is this right? test better. [xw] + return [ + "Expected spy " + this.actual.identity + " to have been called with " + jasmine.pp(expectedArgs) + " but it was never called.", + "Expected spy " + this.actual.identity + " not to have been called with " + jasmine.pp(expectedArgs) + " but it was." + ]; + } else { + return [ + "Expected spy " + this.actual.identity + " to have been called with " + jasmine.pp(expectedArgs) + " but was called with " + jasmine.pp(this.actual.argsForCall), + "Expected spy " + this.actual.identity + " not to have been called with " + jasmine.pp(expectedArgs) + " but was called with " + jasmine.pp(this.actual.argsForCall) + ]; + } + }; + + return this.env.contains_(this.actual.argsForCall, expectedArgs); +}; + +/** @deprecated Use expect(xxx).toHaveBeenCalledWith() instead */ +jasmine.Matchers.prototype.wasCalledWith = jasmine.Matchers.prototype.toHaveBeenCalledWith; + +/** @deprecated Use expect(xxx).not.toHaveBeenCalledWith() instead */ +jasmine.Matchers.prototype.wasNotCalledWith = function() { + var expectedArgs = jasmine.util.argsToArray(arguments); + if (!jasmine.isSpy(this.actual)) { + throw new Error('Expected a spy, but got ' + jasmine.pp(this.actual) + '.'); + } + + this.message = function() { + return [ + "Expected spy not to have been called with " + jasmine.pp(expectedArgs) + " but it was", + "Expected spy to have been called with " + jasmine.pp(expectedArgs) + " but it was" + ]; + }; + + return !this.env.contains_(this.actual.argsForCall, expectedArgs); +}; + +/** + * Matcher that checks that the expected item is an element in the actual Array. + * + * @param {Object} expected + */ +jasmine.Matchers.prototype.toContain = function(expected) { + return this.env.contains_(this.actual, expected); +}; + +/** + * Matcher that checks that the expected item is NOT an element in the actual Array. + * + * @param {Object} expected + * @deprecated as of 1.0. Use not.toNotContain() instead. + */ +jasmine.Matchers.prototype.toNotContain = function(expected) { + return !this.env.contains_(this.actual, expected); +}; + +jasmine.Matchers.prototype.toBeLessThan = function(expected) { + return this.actual < expected; +}; + +jasmine.Matchers.prototype.toBeGreaterThan = function(expected) { + return this.actual > expected; +}; + +/** + * Matcher that checks that the expected item is equal to the actual item + * up to a given level of decimal precision (default 2). + * + * @param {Number} expected + * @param {Number} precision + */ +jasmine.Matchers.prototype.toBeCloseTo = function(expected, precision) { + if (!(precision === 0)) { + precision = precision || 2; + } + var multiplier = Math.pow(10, precision); + var actual = Math.round(this.actual * multiplier); + expected = Math.round(expected * multiplier); + return expected == actual; +}; + +/** + * Matcher that checks that the expected exception was thrown by the actual. + * + * @param {String} expected + */ +jasmine.Matchers.prototype.toThrow = function(expected) { + var result = false; + var exception; + if (typeof this.actual != 'function') { + throw new Error('Actual is not a function'); + } + try { + this.actual(); + } catch (e) { + exception = e; + } + if (exception) { + result = (expected === jasmine.undefined || this.env.equals_(exception.message || exception, expected.message || expected)); + } + + var not = this.isNot ? "not " : ""; + + this.message = function() { + if (exception && (expected === jasmine.undefined || !this.env.equals_(exception.message || exception, expected.message || expected))) { + return ["Expected function " + not + "to throw", expected ? expected.message || expected : "an exception", ", but it threw", exception.message || exception].join(' '); + } else { + return "Expected function to throw an exception."; + } + }; + + return result; +}; + +jasmine.Matchers.Any = function(expectedClass) { + this.expectedClass = expectedClass; +}; + +jasmine.Matchers.Any.prototype.matches = function(other) { + if (this.expectedClass == String) { + return typeof other == 'string' || other instanceof String; + } + + if (this.expectedClass == Number) { + return typeof other == 'number' || other instanceof Number; + } + + if (this.expectedClass == Function) { + return typeof other == 'function' || other instanceof Function; + } + + if (this.expectedClass == Object) { + return typeof other == 'object'; + } + + return other instanceof this.expectedClass; +}; + +jasmine.Matchers.Any.prototype.toString = function() { + return ''; +}; + +/** + * @constructor + */ +jasmine.MultiReporter = function() { + this.subReporters_ = []; +}; +jasmine.util.inherit(jasmine.MultiReporter, jasmine.Reporter); + +jasmine.MultiReporter.prototype.addReporter = function(reporter) { + this.subReporters_.push(reporter); +}; + +(function() { + var functionNames = [ + "reportRunnerStarting", + "reportRunnerResults", + "reportSuiteResults", + "reportSpecStarting", + "reportSpecResults", + "log" + ]; + for (var i = 0; i < functionNames.length; i++) { + var functionName = functionNames[i]; + jasmine.MultiReporter.prototype[functionName] = (function(functionName) { + return function() { + for (var j = 0; j < this.subReporters_.length; j++) { + var subReporter = this.subReporters_[j]; + if (subReporter[functionName]) { + subReporter[functionName].apply(subReporter, arguments); + } + } + }; + })(functionName); + } +})(); +/** + * Holds results for a set of Jasmine spec. Allows for the results array to hold another jasmine.NestedResults + * + * @constructor + */ +jasmine.NestedResults = function() { + /** + * The total count of results + */ + this.totalCount = 0; + /** + * Number of passed results + */ + this.passedCount = 0; + /** + * Number of failed results + */ + this.failedCount = 0; + /** + * Was this suite/spec skipped? + */ + this.skipped = false; + /** + * @ignore + */ + this.items_ = []; +}; + +/** + * Roll up the result counts. + * + * @param result + */ +jasmine.NestedResults.prototype.rollupCounts = function(result) { + this.totalCount += result.totalCount; + this.passedCount += result.passedCount; + this.failedCount += result.failedCount; +}; + +/** + * Adds a log message. + * @param values Array of message parts which will be concatenated later. + */ +jasmine.NestedResults.prototype.log = function(values) { + this.items_.push(new jasmine.MessageResult(values)); +}; + +/** + * Getter for the results: message & results. + */ +jasmine.NestedResults.prototype.getItems = function() { + return this.items_; +}; + +/** + * Adds a result, tracking counts (total, passed, & failed) + * @param {jasmine.ExpectationResult|jasmine.NestedResults} result + */ +jasmine.NestedResults.prototype.addResult = function(result) { + if (result.type != 'log') { + if (result.items_) { + this.rollupCounts(result); + } else { + this.totalCount++; + if (result.passed()) { + this.passedCount++; + } else { + this.failedCount++; + } + } + } + this.items_.push(result); +}; + +/** + * @returns {Boolean} True if everything below passed + */ +jasmine.NestedResults.prototype.passed = function() { + return this.passedCount === this.totalCount; +}; +/** + * Base class for pretty printing for expectation results. + */ +jasmine.PrettyPrinter = function() { + this.ppNestLevel_ = 0; +}; + +/** + * Formats a value in a nice, human-readable string. + * + * @param value + */ +jasmine.PrettyPrinter.prototype.format = function(value) { + if (this.ppNestLevel_ > 40) { + throw new Error('jasmine.PrettyPrinter: format() nested too deeply!'); + } + + this.ppNestLevel_++; + try { + if (value === jasmine.undefined) { + this.emitScalar('undefined'); + } else if (value === null) { + this.emitScalar('null'); + } else if (value === jasmine.getGlobal()) { + this.emitScalar(''); + } else if (value instanceof jasmine.Matchers.Any) { + this.emitScalar(value.toString()); + } else if (typeof value === 'string') { + this.emitString(value); + } else if (jasmine.isSpy(value)) { + this.emitScalar("spy on " + value.identity); + } else if (value instanceof RegExp) { + this.emitScalar(value.toString()); + } else if (typeof value === 'function') { + this.emitScalar('Function'); + } else if (typeof value.nodeType === 'number') { + this.emitScalar('HTMLNode'); + } else if (value instanceof Date) { + this.emitScalar('Date(' + value + ')'); + } else if (value.__Jasmine_been_here_before__) { + this.emitScalar(''); + } else if (jasmine.isArray_(value) || typeof value == 'object') { + value.__Jasmine_been_here_before__ = true; + if (jasmine.isArray_(value)) { + this.emitArray(value); + } else { + this.emitObject(value); + } + delete value.__Jasmine_been_here_before__; + } else { + this.emitScalar(value.toString()); + } + } finally { + this.ppNestLevel_--; + } +}; + +jasmine.PrettyPrinter.prototype.iterateObject = function(obj, fn) { + for (var property in obj) { + if (property == '__Jasmine_been_here_before__') continue; + fn(property, obj.__lookupGetter__ ? (obj.__lookupGetter__(property) !== jasmine.undefined && + obj.__lookupGetter__(property) !== null) : false); + } +}; + +jasmine.PrettyPrinter.prototype.emitArray = jasmine.unimplementedMethod_; +jasmine.PrettyPrinter.prototype.emitObject = jasmine.unimplementedMethod_; +jasmine.PrettyPrinter.prototype.emitScalar = jasmine.unimplementedMethod_; +jasmine.PrettyPrinter.prototype.emitString = jasmine.unimplementedMethod_; + +jasmine.StringPrettyPrinter = function() { + jasmine.PrettyPrinter.call(this); + + this.string = ''; +}; +jasmine.util.inherit(jasmine.StringPrettyPrinter, jasmine.PrettyPrinter); + +jasmine.StringPrettyPrinter.prototype.emitScalar = function(value) { + this.append(value); +}; + +jasmine.StringPrettyPrinter.prototype.emitString = function(value) { + this.append("'" + value + "'"); +}; + +jasmine.StringPrettyPrinter.prototype.emitArray = function(array) { + this.append('[ '); + for (var i = 0; i < array.length; i++) { + if (i > 0) { + this.append(', '); + } + this.format(array[i]); + } + this.append(' ]'); +}; + +jasmine.StringPrettyPrinter.prototype.emitObject = function(obj) { + var self = this; + this.append('{ '); + var first = true; + + this.iterateObject(obj, function(property, isGetter) { + if (first) { + first = false; + } else { + self.append(', '); + } + + self.append(property); + self.append(' : '); + if (isGetter) { + self.append(''); + } else { + self.format(obj[property]); + } + }); + + this.append(' }'); +}; + +jasmine.StringPrettyPrinter.prototype.append = function(value) { + this.string += value; +}; +jasmine.Queue = function(env) { + this.env = env; + this.blocks = []; + this.running = false; + this.index = 0; + this.offset = 0; + this.abort = false; +}; + +jasmine.Queue.prototype.addBefore = function(block) { + this.blocks.unshift(block); +}; + +jasmine.Queue.prototype.add = function(block) { + this.blocks.push(block); +}; + +jasmine.Queue.prototype.insertNext = function(block) { + this.blocks.splice((this.index + this.offset + 1), 0, block); + this.offset++; +}; + +jasmine.Queue.prototype.start = function(onComplete) { + this.running = true; + this.onComplete = onComplete; + this.next_(); +}; + +jasmine.Queue.prototype.isRunning = function() { + return this.running; +}; + +jasmine.Queue.LOOP_DONT_RECURSE = true; + +jasmine.Queue.prototype.next_ = function() { + var self = this; + var goAgain = true; + + while (goAgain) { + goAgain = false; + + if (self.index < self.blocks.length && !this.abort) { + var calledSynchronously = true; + var completedSynchronously = false; + + var onComplete = function () { + if (jasmine.Queue.LOOP_DONT_RECURSE && calledSynchronously) { + completedSynchronously = true; + return; + } + + if (self.blocks[self.index].abort) { + self.abort = true; + } + + self.offset = 0; + self.index++; + + var now = new Date().getTime(); + if (self.env.updateInterval && now - self.env.lastUpdate > self.env.updateInterval) { + self.env.lastUpdate = now; + self.env.setTimeout(function() { + self.next_(); + }, 0); + } else { + if (jasmine.Queue.LOOP_DONT_RECURSE && completedSynchronously) { + goAgain = true; + } else { + self.next_(); + } + } + }; + self.blocks[self.index].execute(onComplete); + + calledSynchronously = false; + if (completedSynchronously) { + onComplete(); + } + + } else { + self.running = false; + if (self.onComplete) { + self.onComplete(); + } + } + } +}; + +jasmine.Queue.prototype.results = function() { + var results = new jasmine.NestedResults(); + for (var i = 0; i < this.blocks.length; i++) { + if (this.blocks[i].results) { + results.addResult(this.blocks[i].results()); + } + } + return results; +}; + + +/** + * Runner + * + * @constructor + * @param {jasmine.Env} env + */ +jasmine.Runner = function(env) { + var self = this; + self.env = env; + self.queue = new jasmine.Queue(env); + self.before_ = []; + self.after_ = []; + self.suites_ = []; +}; + +jasmine.Runner.prototype.execute = function() { + var self = this; + if (self.env.reporter.reportRunnerStarting) { + self.env.reporter.reportRunnerStarting(this); + } + self.queue.start(function () { + self.finishCallback(); + }); +}; + +jasmine.Runner.prototype.beforeEach = function(beforeEachFunction) { + beforeEachFunction.typeName = 'beforeEach'; + this.before_.splice(0,0,beforeEachFunction); +}; + +jasmine.Runner.prototype.afterEach = function(afterEachFunction) { + afterEachFunction.typeName = 'afterEach'; + this.after_.splice(0,0,afterEachFunction); +}; + + +jasmine.Runner.prototype.finishCallback = function() { + this.env.reporter.reportRunnerResults(this); +}; + +jasmine.Runner.prototype.addSuite = function(suite) { + this.suites_.push(suite); +}; + +jasmine.Runner.prototype.add = function(block) { + if (block instanceof jasmine.Suite) { + this.addSuite(block); + } + this.queue.add(block); +}; + +jasmine.Runner.prototype.specs = function () { + var suites = this.suites(); + var specs = []; + for (var i = 0; i < suites.length; i++) { + specs = specs.concat(suites[i].specs()); + } + return specs; +}; + +jasmine.Runner.prototype.suites = function() { + return this.suites_; +}; + +jasmine.Runner.prototype.topLevelSuites = function() { + var topLevelSuites = []; + for (var i = 0; i < this.suites_.length; i++) { + if (!this.suites_[i].parentSuite) { + topLevelSuites.push(this.suites_[i]); + } + } + return topLevelSuites; +}; + +jasmine.Runner.prototype.results = function() { + return this.queue.results(); +}; +/** + * Internal representation of a Jasmine specification, or test. + * + * @constructor + * @param {jasmine.Env} env + * @param {jasmine.Suite} suite + * @param {String} description + */ +jasmine.Spec = function(env, suite, description) { + if (!env) { + throw new Error('jasmine.Env() required'); + } + if (!suite) { + throw new Error('jasmine.Suite() required'); + } + var spec = this; + spec.id = env.nextSpecId ? env.nextSpecId() : null; + spec.env = env; + spec.suite = suite; + spec.description = description; + spec.queue = new jasmine.Queue(env); + + spec.afterCallbacks = []; + spec.spies_ = []; + + spec.results_ = new jasmine.NestedResults(); + spec.results_.description = description; + spec.matchersClass = null; +}; + +jasmine.Spec.prototype.getFullName = function() { + return this.suite.getFullName() + ' ' + this.description + '.'; +}; + + +jasmine.Spec.prototype.results = function() { + return this.results_; +}; + +/** + * All parameters are pretty-printed and concatenated together, then written to the spec's output. + * + * Be careful not to leave calls to jasmine.log in production code. + */ +jasmine.Spec.prototype.log = function() { + return this.results_.log(arguments); +}; + +jasmine.Spec.prototype.runs = function (func) { + var block = new jasmine.Block(this.env, func, this); + this.addToQueue(block); + return this; +}; + +jasmine.Spec.prototype.addToQueue = function (block) { + if (this.queue.isRunning()) { + this.queue.insertNext(block); + } else { + this.queue.add(block); + } +}; + +/** + * @param {jasmine.ExpectationResult} result + */ +jasmine.Spec.prototype.addMatcherResult = function(result) { + this.results_.addResult(result); +}; + +jasmine.Spec.prototype.expect = function(actual) { + var positive = new (this.getMatchersClass_())(this.env, actual, this); + positive.not = new (this.getMatchersClass_())(this.env, actual, this, true); + return positive; +}; + +/** + * Waits a fixed time period before moving to the next block. + * + * @deprecated Use waitsFor() instead + * @param {Number} timeout milliseconds to wait + */ +jasmine.Spec.prototype.waits = function(timeout) { + var waitsFunc = new jasmine.WaitsBlock(this.env, timeout, this); + this.addToQueue(waitsFunc); + return this; +}; + +/** + * Waits for the latchFunction to return true before proceeding to the next block. + * + * @param {Function} latchFunction + * @param {String} optional_timeoutMessage + * @param {Number} optional_timeout + */ +jasmine.Spec.prototype.waitsFor = function(latchFunction, optional_timeoutMessage, optional_timeout) { + var latchFunction_ = null; + var optional_timeoutMessage_ = null; + var optional_timeout_ = null; + + for (var i = 0; i < arguments.length; i++) { + var arg = arguments[i]; + switch (typeof arg) { + case 'function': + latchFunction_ = arg; + break; + case 'string': + optional_timeoutMessage_ = arg; + break; + case 'number': + optional_timeout_ = arg; + break; + } + } + + var waitsForFunc = new jasmine.WaitsForBlock(this.env, optional_timeout_, latchFunction_, optional_timeoutMessage_, this); + this.addToQueue(waitsForFunc); + return this; +}; + +jasmine.Spec.prototype.fail = function (e) { + var expectationResult = new jasmine.ExpectationResult({ + passed: false, + message: e ? jasmine.util.formatException(e) : 'Exception', + trace: { stack: e.stack } + }); + this.results_.addResult(expectationResult); +}; + +jasmine.Spec.prototype.getMatchersClass_ = function() { + return this.matchersClass || this.env.matchersClass; +}; + +jasmine.Spec.prototype.addMatchers = function(matchersPrototype) { + var parent = this.getMatchersClass_(); + var newMatchersClass = function() { + parent.apply(this, arguments); + }; + jasmine.util.inherit(newMatchersClass, parent); + jasmine.Matchers.wrapInto_(matchersPrototype, newMatchersClass); + this.matchersClass = newMatchersClass; +}; + +jasmine.Spec.prototype.finishCallback = function() { + this.env.reporter.reportSpecResults(this); +}; + +jasmine.Spec.prototype.finish = function(onComplete) { + this.removeAllSpies(); + this.finishCallback(); + if (onComplete) { + onComplete(); + } +}; + +jasmine.Spec.prototype.after = function(doAfter) { + if (this.queue.isRunning()) { + this.queue.add(new jasmine.Block(this.env, doAfter, this)); + } else { + this.afterCallbacks.unshift(doAfter); + } +}; + +jasmine.Spec.prototype.execute = function(onComplete) { + var spec = this; + if (!spec.env.specFilter(spec)) { + spec.results_.skipped = true; + spec.finish(onComplete); + return; + } + + this.env.reporter.reportSpecStarting(this); + + spec.env.currentSpec = spec; + + spec.addBeforesAndAftersToQueue(); + + spec.queue.start(function () { + spec.finish(onComplete); + }); +}; + +jasmine.Spec.prototype.addBeforesAndAftersToQueue = function() { + var runner = this.env.currentRunner(); + var i; + + for (var suite = this.suite; suite; suite = suite.parentSuite) { + for (i = 0; i < suite.before_.length; i++) { + this.queue.addBefore(new jasmine.Block(this.env, suite.before_[i], this)); + } + } + for (i = 0; i < runner.before_.length; i++) { + this.queue.addBefore(new jasmine.Block(this.env, runner.before_[i], this)); + } + for (i = 0; i < this.afterCallbacks.length; i++) { + this.queue.add(new jasmine.Block(this.env, this.afterCallbacks[i], this)); + } + for (suite = this.suite; suite; suite = suite.parentSuite) { + for (i = 0; i < suite.after_.length; i++) { + this.queue.add(new jasmine.Block(this.env, suite.after_[i], this)); + } + } + for (i = 0; i < runner.after_.length; i++) { + this.queue.add(new jasmine.Block(this.env, runner.after_[i], this)); + } +}; + +jasmine.Spec.prototype.explodes = function() { + throw 'explodes function should not have been called'; +}; + +jasmine.Spec.prototype.spyOn = function(obj, methodName, ignoreMethodDoesntExist) { + if (obj == jasmine.undefined) { + throw "spyOn could not find an object to spy upon for " + methodName + "()"; + } + + if (!ignoreMethodDoesntExist && obj[methodName] === jasmine.undefined) { + throw methodName + '() method does not exist'; + } + + if (!ignoreMethodDoesntExist && obj[methodName] && obj[methodName].isSpy) { + throw new Error(methodName + ' has already been spied upon'); + } + + var spyObj = jasmine.createSpy(methodName); + + this.spies_.push(spyObj); + spyObj.baseObj = obj; + spyObj.methodName = methodName; + spyObj.originalValue = obj[methodName]; + + obj[methodName] = spyObj; + + return spyObj; +}; + +jasmine.Spec.prototype.removeAllSpies = function() { + for (var i = 0; i < this.spies_.length; i++) { + var spy = this.spies_[i]; + spy.baseObj[spy.methodName] = spy.originalValue; + } + this.spies_ = []; +}; + +/** + * Internal representation of a Jasmine suite. + * + * @constructor + * @param {jasmine.Env} env + * @param {String} description + * @param {Function} specDefinitions + * @param {jasmine.Suite} parentSuite + */ +jasmine.Suite = function(env, description, specDefinitions, parentSuite) { + var self = this; + self.id = env.nextSuiteId ? env.nextSuiteId() : null; + self.description = description; + self.queue = new jasmine.Queue(env); + self.parentSuite = parentSuite; + self.env = env; + self.before_ = []; + self.after_ = []; + self.children_ = []; + self.suites_ = []; + self.specs_ = []; +}; + +jasmine.Suite.prototype.getFullName = function() { + var fullName = this.description; + for (var parentSuite = this.parentSuite; parentSuite; parentSuite = parentSuite.parentSuite) { + fullName = parentSuite.description + ' ' + fullName; + } + return fullName; +}; + +jasmine.Suite.prototype.finish = function(onComplete) { + this.env.reporter.reportSuiteResults(this); + this.finished = true; + if (typeof(onComplete) == 'function') { + onComplete(); + } +}; + +jasmine.Suite.prototype.beforeEach = function(beforeEachFunction) { + beforeEachFunction.typeName = 'beforeEach'; + this.before_.unshift(beforeEachFunction); +}; + +jasmine.Suite.prototype.afterEach = function(afterEachFunction) { + afterEachFunction.typeName = 'afterEach'; + this.after_.unshift(afterEachFunction); +}; + +jasmine.Suite.prototype.results = function() { + return this.queue.results(); +}; + +jasmine.Suite.prototype.add = function(suiteOrSpec) { + this.children_.push(suiteOrSpec); + if (suiteOrSpec instanceof jasmine.Suite) { + this.suites_.push(suiteOrSpec); + this.env.currentRunner().addSuite(suiteOrSpec); + } else { + this.specs_.push(suiteOrSpec); + } + this.queue.add(suiteOrSpec); +}; + +jasmine.Suite.prototype.specs = function() { + return this.specs_; +}; + +jasmine.Suite.prototype.suites = function() { + return this.suites_; +}; + +jasmine.Suite.prototype.children = function() { + return this.children_; +}; + +jasmine.Suite.prototype.execute = function(onComplete) { + var self = this; + this.queue.start(function () { + self.finish(onComplete); + }); +}; +jasmine.WaitsBlock = function(env, timeout, spec) { + this.timeout = timeout; + jasmine.Block.call(this, env, null, spec); +}; + +jasmine.util.inherit(jasmine.WaitsBlock, jasmine.Block); + +jasmine.WaitsBlock.prototype.execute = function (onComplete) { + if (jasmine.VERBOSE) { + this.env.reporter.log('>> Jasmine waiting for ' + this.timeout + ' ms...'); + } + this.env.setTimeout(function () { + onComplete(); + }, this.timeout); +}; +/** + * A block which waits for some condition to become true, with timeout. + * + * @constructor + * @extends jasmine.Block + * @param {jasmine.Env} env The Jasmine environment. + * @param {Number} timeout The maximum time in milliseconds to wait for the condition to become true. + * @param {Function} latchFunction A function which returns true when the desired condition has been met. + * @param {String} message The message to display if the desired condition hasn't been met within the given time period. + * @param {jasmine.Spec} spec The Jasmine spec. + */ +jasmine.WaitsForBlock = function(env, timeout, latchFunction, message, spec) { + this.timeout = timeout || env.defaultTimeoutInterval; + this.latchFunction = latchFunction; + this.message = message; + this.totalTimeSpentWaitingForLatch = 0; + jasmine.Block.call(this, env, null, spec); +}; +jasmine.util.inherit(jasmine.WaitsForBlock, jasmine.Block); + +jasmine.WaitsForBlock.TIMEOUT_INCREMENT = 10; + +jasmine.WaitsForBlock.prototype.execute = function(onComplete) { + if (jasmine.VERBOSE) { + this.env.reporter.log('>> Jasmine waiting for ' + (this.message || 'something to happen')); + } + var latchFunctionResult; + try { + latchFunctionResult = this.latchFunction.apply(this.spec); + } catch (e) { + this.spec.fail(e); + onComplete(); + return; + } + + if (latchFunctionResult) { + onComplete(); + } else if (this.totalTimeSpentWaitingForLatch >= this.timeout) { + var message = 'timed out after ' + this.timeout + ' msec waiting for ' + (this.message || 'something to happen'); + this.spec.fail({ + name: 'timeout', + message: message + }); + + this.abort = true; + onComplete(); + } else { + this.totalTimeSpentWaitingForLatch += jasmine.WaitsForBlock.TIMEOUT_INCREMENT; + var self = this; + this.env.setTimeout(function() { + self.execute(onComplete); + }, jasmine.WaitsForBlock.TIMEOUT_INCREMENT); + } +}; +// Mock setTimeout, clearTimeout +// Contributed by Pivotal Computer Systems, www.pivotalsf.com + +jasmine.FakeTimer = function() { + this.reset(); + + var self = this; + self.setTimeout = function(funcToCall, millis) { + self.timeoutsMade++; + self.scheduleFunction(self.timeoutsMade, funcToCall, millis, false); + return self.timeoutsMade; + }; + + self.setInterval = function(funcToCall, millis) { + self.timeoutsMade++; + self.scheduleFunction(self.timeoutsMade, funcToCall, millis, true); + return self.timeoutsMade; + }; + + self.clearTimeout = function(timeoutKey) { + self.scheduledFunctions[timeoutKey] = jasmine.undefined; + }; + + self.clearInterval = function(timeoutKey) { + self.scheduledFunctions[timeoutKey] = jasmine.undefined; + }; + +}; + +jasmine.FakeTimer.prototype.reset = function() { + this.timeoutsMade = 0; + this.scheduledFunctions = {}; + this.nowMillis = 0; +}; + +jasmine.FakeTimer.prototype.tick = function(millis) { + var oldMillis = this.nowMillis; + var newMillis = oldMillis + millis; + this.runFunctionsWithinRange(oldMillis, newMillis); + this.nowMillis = newMillis; +}; + +jasmine.FakeTimer.prototype.runFunctionsWithinRange = function(oldMillis, nowMillis) { + var scheduledFunc; + var funcsToRun = []; + for (var timeoutKey in this.scheduledFunctions) { + scheduledFunc = this.scheduledFunctions[timeoutKey]; + if (scheduledFunc != jasmine.undefined && + scheduledFunc.runAtMillis >= oldMillis && + scheduledFunc.runAtMillis <= nowMillis) { + funcsToRun.push(scheduledFunc); + this.scheduledFunctions[timeoutKey] = jasmine.undefined; + } + } + + if (funcsToRun.length > 0) { + funcsToRun.sort(function(a, b) { + return a.runAtMillis - b.runAtMillis; + }); + for (var i = 0; i < funcsToRun.length; ++i) { + try { + var funcToRun = funcsToRun[i]; + this.nowMillis = funcToRun.runAtMillis; + funcToRun.funcToCall(); + if (funcToRun.recurring) { + this.scheduleFunction(funcToRun.timeoutKey, + funcToRun.funcToCall, + funcToRun.millis, + true); + } + } catch(e) { + } + } + this.runFunctionsWithinRange(oldMillis, nowMillis); + } +}; + +jasmine.FakeTimer.prototype.scheduleFunction = function(timeoutKey, funcToCall, millis, recurring) { + this.scheduledFunctions[timeoutKey] = { + runAtMillis: this.nowMillis + millis, + funcToCall: funcToCall, + recurring: recurring, + timeoutKey: timeoutKey, + millis: millis + }; +}; + +/** + * @namespace + */ +jasmine.Clock = { + defaultFakeTimer: new jasmine.FakeTimer(), + + reset: function() { + jasmine.Clock.assertInstalled(); + jasmine.Clock.defaultFakeTimer.reset(); + }, + + tick: function(millis) { + jasmine.Clock.assertInstalled(); + jasmine.Clock.defaultFakeTimer.tick(millis); + }, + + runFunctionsWithinRange: function(oldMillis, nowMillis) { + jasmine.Clock.defaultFakeTimer.runFunctionsWithinRange(oldMillis, nowMillis); + }, + + scheduleFunction: function(timeoutKey, funcToCall, millis, recurring) { + jasmine.Clock.defaultFakeTimer.scheduleFunction(timeoutKey, funcToCall, millis, recurring); + }, + + useMock: function() { + if (!jasmine.Clock.isInstalled()) { + var spec = jasmine.getEnv().currentSpec; + spec.after(jasmine.Clock.uninstallMock); + + jasmine.Clock.installMock(); + } + }, + + installMock: function() { + jasmine.Clock.installed = jasmine.Clock.defaultFakeTimer; + }, + + uninstallMock: function() { + jasmine.Clock.assertInstalled(); + jasmine.Clock.installed = jasmine.Clock.real; + }, + + real: { + setTimeout: jasmine.getGlobal().setTimeout, + clearTimeout: jasmine.getGlobal().clearTimeout, + setInterval: jasmine.getGlobal().setInterval, + clearInterval: jasmine.getGlobal().clearInterval + }, + + assertInstalled: function() { + if (!jasmine.Clock.isInstalled()) { + throw new Error("Mock clock is not installed, use jasmine.Clock.useMock()"); + } + }, + + isInstalled: function() { + return jasmine.Clock.installed == jasmine.Clock.defaultFakeTimer; + }, + + installed: null +}; +jasmine.Clock.installed = jasmine.Clock.real; + +//else for IE support +jasmine.getGlobal().setTimeout = function(funcToCall, millis) { + if (jasmine.Clock.installed.setTimeout.apply) { + return jasmine.Clock.installed.setTimeout.apply(this, arguments); + } else { + return jasmine.Clock.installed.setTimeout(funcToCall, millis); + } +}; + +jasmine.getGlobal().setInterval = function(funcToCall, millis) { + if (jasmine.Clock.installed.setInterval.apply) { + return jasmine.Clock.installed.setInterval.apply(this, arguments); + } else { + return jasmine.Clock.installed.setInterval(funcToCall, millis); + } +}; + +jasmine.getGlobal().clearTimeout = function(timeoutKey) { + if (jasmine.Clock.installed.clearTimeout.apply) { + return jasmine.Clock.installed.clearTimeout.apply(this, arguments); + } else { + return jasmine.Clock.installed.clearTimeout(timeoutKey); + } +}; + +jasmine.getGlobal().clearInterval = function(timeoutKey) { + if (jasmine.Clock.installed.clearTimeout.apply) { + return jasmine.Clock.installed.clearInterval.apply(this, arguments); + } else { + return jasmine.Clock.installed.clearInterval(timeoutKey); + } +}; + +jasmine.version_= { + "major": 1, + "minor": 1, + "build": 0, + "revision": 1315677058 +}; diff --git a/spec/js/jasmine/jasmine_favicon.png b/spec/js/jasmine/jasmine_favicon.png new file mode 100644 index 00000000..218f3b43 Binary files /dev/null and b/spec/js/jasmine/jasmine_favicon.png differ diff --git a/spec/js/locales.spec.js b/spec/js/locales.spec.js new file mode 100644 index 00000000..94d57008 --- /dev/null +++ b/spec/js/locales.spec.js @@ -0,0 +1,31 @@ +var I18n = require("../../app/assets/javascripts/i18n"); + +describe("Locales", function(){ + beforeEach(function(){ + I18n.reset(); + }); + + it("returns the requested locale, if available", function(){ + I18n.locales["ab"] = ["ab"]; + expect(I18n.locales.get("ab")).toEqual(["ab"]); + }); + + it("wraps single results in an array", function(){ + I18n.locales["cd"] = "cd"; + expect(I18n.locales.get("cd")).toEqual(["cd"]); + }); + + it("returns the result of locale functions", function(){ + I18n.locales["fn"] = function() { + return "gg"; + }; + expect(I18n.locales.get("fn")).toEqual(["gg"]); + }); + + it("uses I18n.locale as a fallback", function(){ + I18n.locale = "xx"; + I18n.locales["xx"] = ["xx"]; + expect(I18n.locales.get()).toEqual(["xx"]); + expect(I18n.locales.get("yy")).toEqual(["xx"]); + }); +}); diff --git a/spec/js/localization.spec.js b/spec/js/localization.spec.js new file mode 100644 index 00000000..df08f94f --- /dev/null +++ b/spec/js/localization.spec.js @@ -0,0 +1,78 @@ +var I18n = require("../../app/assets/javascripts/i18n") + , Translations = require("./translations") +; + +describe("Localization", function(){ + var actual, expected; + + beforeEach(function() { + I18n.reset(); + I18n.translations = Translations(); + }); + + it("sets bound alias", function() { + expect(I18n.l).toEqual(jasmine.any(Function)); + expect(I18n.l).not.toBe(I18n.localize); + }); + + it("localizes number", function(){ + expect(I18n.localize("number", 1234567)).toEqual("1,234,567.000"); + }); + + it("localizes number with 'l' shortcut", function(){ + var l = I18n.l; + expect(l("number", 1234567)).toEqual("1,234,567.000"); + }); + + it("localizes currency", function(){ + expect(I18n.localize("currency", 1234567)).toEqual("$1,234,567.00"); + }); + + it("localizes date strings", function(){ + I18n.locale = "pt-BR"; + + expect(I18n.localize("date.formats.default", "2009-11-29")).toEqual("29/11/2009"); + expect(I18n.localize("date.formats.short", "2009-01-07")).toEqual("07 de Janeiro"); + expect(I18n.localize("date.formats.long", "2009-01-07")).toEqual("07 de Janeiro de 2009"); + }); + + it("localizes strings with locale from options", function(){ + I18n.locale = "en"; + + expect(I18n.localize("date.formats.default", "2009-11-29", { locale: "pt-BR" })).toEqual("29/11/2009"); + expect(I18n.localize("date.formats.short", "2009-01-07", { locale: "pt-BR" })).toEqual("07 de Janeiro"); + expect(I18n.localize("date.formats.long", "2009-01-07", { locale: "pt-BR" })).toEqual("07 de Janeiro de 2009"); + expect(I18n.localize("time.formats.default", "2009-11-29 15:07:59", { locale: "pt-BR" })).toEqual("Domingo, 29 de Novembro de 2009, 15:07 h"); + expect(I18n.localize("time.formats.short", "2009-01-07 09:12:35", { locale: "pt-BR" })).toEqual("07/01, 09:12 h"); + expect(I18n.localize("time.formats.long", "2009-11-29 15:07:59", { locale: "pt-BR" })).toEqual("Domingo, 29 de Novembro de 2009, 15:07 h"); + expect(I18n.localize("number", 1234567, { locale: "pt-BR" })).toEqual("1,234,567.000"); + expect(I18n.localize("currency", 1234567, { locale: "pt-BR" })).toEqual("R$ 1.234.567,00"); + expect(I18n.localize("percentage", 123.45, { locale: "pt-BR" })).toEqual("123,45%"); + }); + + it("localizes time strings", function(){ + I18n.locale = "pt-BR"; + + expect(I18n.localize("time.formats.default", "2009-11-29 15:07:59")).toEqual("Domingo, 29 de Novembro de 2009, 15:07 h"); + expect(I18n.localize("time.formats.short", "2009-01-07 09:12:35")).toEqual("07/01, 09:12 h"); + expect(I18n.localize("time.formats.long", "2009-11-29 15:07:59")).toEqual("Domingo, 29 de Novembro de 2009, 15:07 h"); + }); + + it("return 'Invalid Date' or original value for invalid input", function(){ + expect(I18n.localize("time.formats.default", "")).toEqual("Invalid Date"); + expect(I18n.localize("time.formats.default", null)).toEqual(null); + expect(I18n.localize("time.formats.default", undefined)).toEqual(undefined); + }); + + it("localizes date/time strings with placeholders", function(){ + I18n.locale = "pt-BR"; + + expect(I18n.localize("date.formats.short_with_placeholders", "2009-01-07", { p1: "!", p2: "?" })).toEqual("07 de Janeiro ! ?"); + expect(I18n.localize("time.formats.short_with_placeholders", "2009-01-07 09:12:35", { p1: "!" })).toEqual("07/01, 09:12 h !"); + }); + + it("localizes percentage", function(){ + I18n.locale = "pt-BR"; + expect(I18n.localize("percentage", 123.45)).toEqual("123,45%"); + }); +}); diff --git a/spec/js/numbers.spec.js b/spec/js/numbers.spec.js new file mode 100644 index 00000000..8fcc4544 --- /dev/null +++ b/spec/js/numbers.spec.js @@ -0,0 +1,174 @@ +var I18n = require("../../app/assets/javascripts/i18n") + , Translations = require("./translations") +; + +describe("Numbers", function(){ + var actual, expected; + + beforeEach(function() { + I18n.reset(); + I18n.translations = Translations(); + }); + + it("formats number with default settings", function(){ + expect(I18n.toNumber(1)).toEqual("1.000"); + expect(I18n.toNumber(12)).toEqual("12.000"); + expect(I18n.toNumber(123)).toEqual("123.000"); + expect(I18n.toNumber(1234)).toEqual("1,234.000"); + expect(I18n.toNumber(12345)).toEqual("12,345.000"); + expect(I18n.toNumber(123456)).toEqual("123,456.000"); + expect(I18n.toNumber(1234567)).toEqual("1,234,567.000"); + expect(I18n.toNumber(12345678)).toEqual("12,345,678.000"); + expect(I18n.toNumber(123456789)).toEqual("123,456,789.000"); + }); + + it("formats negative numbers with default settings", function(){ + expect(I18n.toNumber(-1)).toEqual("-1.000"); + expect(I18n.toNumber(-12)).toEqual("-12.000"); + expect(I18n.toNumber(-123)).toEqual("-123.000"); + expect(I18n.toNumber(-1234)).toEqual("-1,234.000"); + expect(I18n.toNumber(-12345)).toEqual("-12,345.000"); + expect(I18n.toNumber(-123456)).toEqual("-123,456.000"); + expect(I18n.toNumber(-1234567)).toEqual("-1,234,567.000"); + expect(I18n.toNumber(-12345678)).toEqual("-12,345,678.000"); + expect(I18n.toNumber(-123456789)).toEqual("-123,456,789.000"); + }); + + it("formats number with partial translation and default options", function(){ + I18n.translations.en.number = { + format: { + precision: 2 + } + }; + + expect(I18n.toNumber(1234)).toEqual("1,234.00"); + }); + + it("formats number with full translation and default options", function(){ + I18n.translations.en.number = { + format: { + delimiter: ".", + separator: ",", + precision: 2 + } + }; + + expect(I18n.toNumber(1234)).toEqual("1.234,00"); + }); + + it("formats numbers with some custom options that should be merged with default options", function(){ + expect(I18n.toNumber(1234.56, {precision: 0})).toEqual("1,235"); + expect(I18n.toNumber(1234, {separator: '-'})).toEqual("1,234-000"); + expect(I18n.toNumber(1234, {delimiter: '_'})).toEqual("1_234.000"); + }); + + it("formats number considering options", function(){ + options = { + precision: 2, + separator: ",", + delimiter: "." + }; + + expect(I18n.toNumber(1, options)).toEqual("1,00"); + expect(I18n.toNumber(12, options)).toEqual("12,00"); + expect(I18n.toNumber(123, options)).toEqual("123,00"); + expect(I18n.toNumber(1234, options)).toEqual("1.234,00"); + expect(I18n.toNumber(123456, options)).toEqual("123.456,00"); + expect(I18n.toNumber(1234567, options)).toEqual("1.234.567,00"); + expect(I18n.toNumber(12345678, options)).toEqual("12.345.678,00"); + }); + + it("formats numbers with different precisions", function(){ + options = {separator: ".", delimiter: ","}; + + options["precision"] = 2; + expect(I18n.toNumber(1.98, options)).toEqual("1.98"); + + options["precision"] = 3; + expect(I18n.toNumber(1.98, options)).toEqual("1.980"); + + options["precision"] = 2; + expect(I18n.toNumber(1.987, options)).toEqual("1.99"); + + options["precision"] = 1; + expect(I18n.toNumber(1.98, options)).toEqual("2.0"); + + options["precision"] = 0; + expect(I18n.toNumber(1.98, options)).toEqual("2"); + }); + + it("rounds numbers correctly when precision is given", function(){ + options = {separator: ".", delimiter: ","}; + + options["precision"] = 2; + expect(I18n.toNumber(0.104, options)).toEqual("0.10"); + + options["precision"] = 2; + expect(I18n.toNumber(0.105, options)).toEqual("0.11"); + + options["precision"] = 2; + expect(I18n.toNumber(1.005, options)).toEqual("1.01"); + + options["precision"] = 3; + expect(I18n.toNumber(35.855, options)).toEqual("35.855"); + + options["precision"] = 2; + expect(I18n.toNumber(35.855, options)).toEqual("35.86"); + + options["precision"] = 1; + expect(I18n.toNumber(35.855, options)).toEqual("35.9"); + + options["precision"] = 0; + expect(I18n.toNumber(35.855, options)).toEqual("36"); + + options["precision"] = 0; + expect(I18n.toNumber(0.000000000000001, options)).toEqual("0"); + }); + + it("returns number as human size", function(){ + var kb = 1024; + + expect(I18n.toHumanSize(1)).toEqual("1Byte"); + expect(I18n.toHumanSize(100)).toEqual("100Bytes"); + + expect(I18n.toHumanSize(kb)).toEqual("1KB"); + expect(I18n.toHumanSize(kb * 1.5)).toEqual("1.5KB"); + + expect(I18n.toHumanSize(kb * kb)).toEqual("1MB"); + expect(I18n.toHumanSize(kb * kb * 1.5)).toEqual("1.5MB"); + + expect(I18n.toHumanSize(kb * kb * kb)).toEqual("1GB"); + expect(I18n.toHumanSize(kb * kb * kb * 1.5)).toEqual("1.5GB"); + + expect(I18n.toHumanSize(kb * kb * kb * kb)).toEqual("1TB"); + expect(I18n.toHumanSize(kb * kb * kb * kb * 1.5)).toEqual("1.5TB"); + + expect(I18n.toHumanSize(kb * kb * kb * kb * kb)).toEqual("1024TB"); + }); + + it("returns number as human size using custom options", function(){ + expect(I18n.toHumanSize(1024 * 1.6, {precision: 0})).toEqual("2KB"); + }); + + it("returns number as human size using custom scope", function(){ + expect(I18n.toHumanSize(1024 * 1024, {scope: "extended"})).toEqual("1Megabyte"); + }); + + it("formats numbers with strip insignificant zero", function() { + options = {separator: ".", delimiter: ",", strip_insignificant_zeros: true}; + + options["precision"] = 2; + expect(I18n.toNumber(1.0, options)).toEqual("1"); + + options["precision"] = 3; + expect(I18n.toNumber(1.98, options)).toEqual("1.98"); + + options["precision"] = 4; + expect(I18n.toNumber(1.987, options)).toEqual("1.987"); + }); + + it("keeps significant zeros [issue#103]", function() { + actual = I18n.toNumber(30, {strip_insignificant_zeros: true, precision: 0}); + expect(actual).toEqual("30"); + }); +}); diff --git a/spec/js/placeholder.spec.js b/spec/js/placeholder.spec.js new file mode 100644 index 00000000..8e5e44ca --- /dev/null +++ b/spec/js/placeholder.spec.js @@ -0,0 +1,24 @@ +var I18n = require("../../app/assets/javascripts/i18n"); + +describe("Placeholder", function(){ + beforeEach(function(){ + I18n.reset(); + }); + + it("matches {{name}}", function(){ + expect("{{name}}").toMatch(I18n.placeholder); + }); + + it("matches %{name}", function(){ + expect("%{name}").toMatch(I18n.placeholder); + }); + + it("returns placeholders", function(){ + var translation = "I like %{javascript}. I also like %{ruby}" + , matches = translation.match(I18n.placeholder); + ; + + expect(matches[0]).toEqual("%{javascript}"); + expect(matches[1]).toEqual("%{ruby}"); + }); +}); diff --git a/spec/js/pluralization.spec.js b/spec/js/pluralization.spec.js new file mode 100644 index 00000000..d47d53cc --- /dev/null +++ b/spec/js/pluralization.spec.js @@ -0,0 +1,219 @@ +var I18n = require("../../app/assets/javascripts/i18n") + , Translations = require("./translations") +; + +describe("Pluralization", function(){ + var actual, expected; + + beforeEach(function(){ + I18n.reset(); + I18n.translations = Translations(); + }); + + it("sets bound alias", function() { + expect(I18n.p).toEqual(jasmine.any(Function)); + expect(I18n.p).not.toBe(I18n.pluralize); + }); + + it("pluralizes scope", function(){ + expect(I18n.p(0, "inbox")).toEqual("You have no messages"); + expect(I18n.p(1, "inbox")).toEqual("You have 1 message"); + expect(I18n.p(5, "inbox")).toEqual("You have 5 messages"); + }); + + it("pluralizes scope with 'p' shortcut", function(){ + var p = I18n.p; + expect(p(0, "inbox")).toEqual("You have no messages"); + expect(p(1, "inbox")).toEqual("You have 1 message"); + expect(p(5, "inbox")).toEqual("You have 5 messages"); + }); + + it("pluralizes using the 'other' scope", function(){ + I18n.translations["en"]["inbox"]["zero"] = null; + expect(I18n.p(0, "inbox")).toEqual("You have 0 messages"); + }); + + it("pluralizes using the 'zero' scope", function(){ + I18n.translations["en"]["inbox"]["zero"] = "No messages (zero)"; + + expect(I18n.p(0, "inbox")).toEqual("No messages (zero)"); + }); + + it("pluralizes using negative values", function(){ + expect(I18n.p(-1, "inbox")).toEqual("You have -1 messages"); + expect(I18n.p(-5, "inbox")).toEqual("You have -5 messages"); + }); + + it("returns missing translation", function(){ + expect(I18n.p(-1, "missing")).toEqual('[missing "en.missing" translation]'); + }); + + it("pluralizes using multiple placeholders", function(){ + actual = I18n.p(1, "unread", {unread: 5}); + expect(actual).toEqual("You have 1 new message (5 unread)"); + + actual = I18n.p(10, "unread", {unread: 2}); + expect(actual).toEqual("You have 10 new messages (2 unread)"); + + actual = I18n.p(0, "unread", {unread: 5}); + expect(actual).toEqual("You have no new messages (5 unread)"); + }); + + it("allows empty strings", function(){ + I18n.translations["en"]["inbox"]["zero"] = ""; + + expect(I18n.p(0, "inbox")).toEqual(""); + }); + + it("returns missing message on null values", function(){ + I18n.translations["en"]["sent"]["zero"] = null; + I18n.translations["en"]["sent"]["one"] = null; + I18n.translations["en"]["sent"]["other"] = null; + + expect(I18n.p(0, "sent")).toEqual('[missing "en.sent.zero" translation]'); + expect(I18n.p(1, "sent")).toEqual('[missing "en.sent.one" translation]'); + expect(I18n.p(5, "sent")).toEqual('[missing "en.sent.other" translation]'); + }); + + it("pluralizes using custom rules", function() { + I18n.locale = "custom"; + + I18n.pluralization["custom"] = function(count) { + if (count === 0) { return ["zero"]; } + if (count >= 1 && count <= 5) { return ["few", "other"]; } + return ["other"]; + }; + + I18n.translations["custom"] = { + "things": { + "zero": "No things" + , "few": "A few things" + , "other": "%{count} things" + } + } + + expect(I18n.p(0, "things")).toEqual("No things"); + expect(I18n.p(4, "things")).toEqual("A few things"); + expect(I18n.p(-4, "things")).toEqual("-4 things"); + expect(I18n.p(10, "things")).toEqual("10 things"); + }); + + it("pluralizes default value", function(){ + options = {defaultValue: { + zero: "No things here!" + , one: "There is {{count}} thing here!" + , other: "There are {{count}} things here!" + }}; + + expect(I18n.p(0, "things", options)).toEqual("No things here!"); + expect(I18n.p(1, "things", options)).toEqual("There is 1 thing here!"); + expect(I18n.p(5, "things", options)).toEqual("There are 5 things here!"); + }); + + it("ignores pluralization when scope exists", function(){ + options = {defaultValue: { + zero: "No things here!" + , one: "There is {{count}} thing here!" + , other: "There are {{count}} things here!" + }}; + + expect(I18n.p(0, "inbox", options)).toEqual("You have no messages"); + expect(I18n.p(1, "inbox", options)).toEqual("You have 1 message"); + expect(I18n.p(5, "inbox", options)).toEqual("You have 5 messages"); + }); + + it("fallback to default locale when I18n.fallbacks is enabled", function() { + I18n.locale = "pt-BR"; + I18n.fallbacks = true; + I18n.translations["pt-BR"].inbox= { + one: null + , other: null + , zero: null + }; + expect(I18n.p(0, "inbox", { count: 0 })).toEqual("You have no messages"); + expect(I18n.p(1, "inbox", { count: 1 })).toEqual("You have 1 message"); + expect(I18n.p(5, "inbox", { count: 5 })).toEqual("You have 5 messages"); + }); + + it("fallback to default locale when I18n.fallbacks is enabled", function() { + I18n.locale = "pt-BR"; + I18n.fallbacks = true; + I18n.translations["pt-BR"].inbox= { + one: "Você tem uma mensagem" + , other: null + , zero: "Você não tem nenhuma mensagem" + }; + expect(I18n.p(0, "inbox", { count: 0 })).toEqual("Você não tem nenhuma mensagem"); + expect(I18n.p(1, "inbox", { count: 1 })).toEqual("Você tem uma mensagem"); + expect(I18n.p(5, "inbox", { count: 5 })).toEqual('You have 5 messages'); + }); + + it("fallback to 'other' scope", function() { + I18n.locale = "pt-BR"; + I18n.fallbacks = true; + I18n.translations["pt-BR"].inbox= { + one: "Você tem uma mensagem" + , other: "Você tem {{count}} mensagens" + , zero: null + } + expect(I18n.p(0, "inbox", { count: 0 })).toEqual("Você tem 0 mensagens"); + expect(I18n.p(1, "inbox", { count: 1 })).toEqual("Você tem uma mensagem"); + expect(I18n.p(5, "inbox", { count: 5 })).toEqual("Você tem 5 mensagens"); + }); + + it("fallback to defaulValue when defaultValue is string", function() { + I18n.locale = "pt-BR"; + I18n.fallbacks = true; + I18n.translations["en"]["inbox"]["zero"] = null; + I18n.translations["en"]["inbox"]["one"] = null; + I18n.translations["en"]["inbox"]["other"] = null; + I18n.translations["pt-BR"].inbox= { + one: "Você tem uma mensagem" + , other: null + , zero: null + } + options = { + defaultValue: "default message" + }; + expect(I18n.p(0, "inbox", options)).toEqual("default message"); + expect(I18n.p(1, "inbox", options)).toEqual("Você tem uma mensagem"); + expect(I18n.p(5, "inbox", options)).toEqual("default message"); + }); + + it("fallback to defaulValue when defaultValue is an object", function() { + I18n.locale = "pt-BR"; + I18n.fallbacks = true; + I18n.translations["en"]["inbox"]["zero"] = null; + I18n.translations["en"]["inbox"]["one"] = null; + I18n.translations["en"]["inbox"]["other"] = null; + I18n.translations["pt-BR"].inbox= { + one: "Você tem uma mensagem" + , other: null + , zero: null + } + options = { + defaultValue: { + zero: "default message for no message" + , one: "default message for 1 message" + , other: "default message for {{count}} messages" + } + }; + expect(I18n.p(0, "inbox", options)).toEqual("default message for no message"); + expect(I18n.p(1, "inbox", options)).toEqual("Você tem uma mensagem"); + expect(I18n.p(5, "inbox", options)).toEqual("default message for 5 messages"); + }); + + it("fallback to default locale when I18n.fallbacks is enabled and no translations in sub scope", function() { + I18n.locale = "pt-BR"; + I18n.fallbacks = true; + I18n.translations["en"]["mailbox"] = { + inbox: I18n.translations["en"].inbox + } + + expect(I18n.translations["pt-BR"]["mailbox"]).toEqual(undefined); + expect(I18n.p(0, "mailbox.inbox", { count: 0 })).toEqual("You have no messages"); + expect(I18n.p(1, "mailbox.inbox", { count: 1 })).toEqual("You have 1 message"); + expect(I18n.p(5, "mailbox.inbox", { count: 5 })).toEqual("You have 5 messages"); + }); + +}); diff --git a/spec/js/prepare_options.spec.js b/spec/js/prepare_options.spec.js new file mode 100644 index 00000000..d4e6d8ca --- /dev/null +++ b/spec/js/prepare_options.spec.js @@ -0,0 +1,41 @@ +var I18n = require("../../app/assets/javascripts/i18n"); + +describe("Prepare options", function(){ + beforeEach(function(){ + I18n.reset(); + }); + + it("merges two objects", function(){ + var options = I18n.prepareOptions( + {name: "Mary Doe"}, + {name: "John Doe", role: "user"} + ); + + expect(options.name).toEqual("Mary Doe"); + expect(options.role).toEqual("user"); + }); + + it("merges multiple objects", function(){ + var options = I18n.prepareOptions( + {name: "Mary Doe"}, + {name: "John Doe", role: "user"}, + {age: 33}, + {email: "mary@doe.com", url: "http://marydoe.com"}, + {role: "admin", email: "john@doe.com"} + ); + + expect(options.name).toEqual("Mary Doe"); + expect(options.role).toEqual("user"); + expect(options.age).toEqual(33); + expect(options.email).toEqual("mary@doe.com"); + expect(options.url).toEqual("http://marydoe.com"); + }); + + it("returns an empty object when values are null", function(){ + expect(I18n.prepareOptions(null, null)).toEqual({}); + }); + + it("returns an empty object when values are undefined", function(){ + expect(I18n.prepareOptions(undefined, undefined)).toEqual({}); + }); +}); diff --git a/spec/js/require.js b/spec/js/require.js new file mode 100644 index 00000000..bc43457f --- /dev/null +++ b/spec/js/require.js @@ -0,0 +1,2083 @@ +/** vim: et:ts=4:sw=4:sts=4 + * @license RequireJS 2.1.16 Copyright (c) 2010-2015, The Dojo Foundation All Rights Reserved. + * Available via the MIT or new BSD license. + * see: http://github.com/jrburke/requirejs for details + */ +//Not using strict: uneven strict support in browsers, #392, and causes +//problems with requirejs.exec()/transpiler plugins that may not be strict. +/*jslint regexp: true, nomen: true, sloppy: true */ +/*global window, navigator, document, importScripts, setTimeout, opera */ + +var requirejs, require, define; +(function (global) { + var req, s, head, baseElement, dataMain, src, + interactiveScript, currentlyAddingScript, mainScript, subPath, + version = '2.1.16', + commentRegExp = /(\/\*([\s\S]*?)\*\/|([^:]|^)\/\/(.*)$)/mg, + cjsRequireRegExp = /[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g, + jsSuffixRegExp = /\.js$/, + currDirRegExp = /^\.\//, + op = Object.prototype, + ostring = op.toString, + hasOwn = op.hasOwnProperty, + ap = Array.prototype, + apsp = ap.splice, + isBrowser = !!(typeof window !== 'undefined' && typeof navigator !== 'undefined' && window.document), + isWebWorker = !isBrowser && typeof importScripts !== 'undefined', + //PS3 indicates loaded and complete, but need to wait for complete + //specifically. Sequence is 'loading', 'loaded', execution, + // then 'complete'. The UA check is unfortunate, but not sure how + //to feature test w/o causing perf issues. + readyRegExp = isBrowser && navigator.platform === 'PLAYSTATION 3' ? + /^complete$/ : /^(complete|loaded)$/, + defContextName = '_', + //Oh the tragedy, detecting opera. See the usage of isOpera for reason. + isOpera = typeof opera !== 'undefined' && opera.toString() === '[object Opera]', + contexts = {}, + cfg = {}, + globalDefQueue = [], + useInteractive = false; + + function isFunction(it) { + return ostring.call(it) === '[object Function]'; + } + + function isArray(it) { + return ostring.call(it) === '[object Array]'; + } + + /** + * Helper function for iterating over an array. If the func returns + * a true value, it will break out of the loop. + */ + function each(ary, func) { + if (ary) { + var i; + for (i = 0; i < ary.length; i += 1) { + if (ary[i] && func(ary[i], i, ary)) { + break; + } + } + } + } + + /** + * Helper function for iterating over an array backwards. If the func + * returns a true value, it will break out of the loop. + */ + function eachReverse(ary, func) { + if (ary) { + var i; + for (i = ary.length - 1; i > -1; i -= 1) { + if (ary[i] && func(ary[i], i, ary)) { + break; + } + } + } + } + + function hasProp(obj, prop) { + return hasOwn.call(obj, prop); + } + + function getOwn(obj, prop) { + return hasProp(obj, prop) && obj[prop]; + } + + /** + * Cycles over properties in an object and calls a function for each + * property value. If the function returns a truthy value, then the + * iteration is stopped. + */ + function eachProp(obj, func) { + var prop; + for (prop in obj) { + if (hasProp(obj, prop)) { + if (func(obj[prop], prop)) { + break; + } + } + } + } + + /** + * Simple function to mix in properties from source into target, + * but only if target does not already have a property of the same name. + */ + function mixin(target, source, force, deepStringMixin) { + if (source) { + eachProp(source, function (value, prop) { + if (force || !hasProp(target, prop)) { + if (deepStringMixin && typeof value === 'object' && value && + !isArray(value) && !isFunction(value) && + !(value instanceof RegExp)) { + + if (!target[prop]) { + target[prop] = {}; + } + mixin(target[prop], value, force, deepStringMixin); + } else { + target[prop] = value; + } + } + }); + } + return target; + } + + //Similar to Function.prototype.bind, but the 'this' object is specified + //first, since it is easier to read/figure out what 'this' will be. + function bind(obj, fn) { + return function () { + return fn.apply(obj, arguments); + }; + } + + function scripts() { + return document.getElementsByTagName('script'); + } + + function defaultOnError(err) { + throw err; + } + + //Allow getting a global that is expressed in + //dot notation, like 'a.b.c'. + function getGlobal(value) { + if (!value) { + return value; + } + var g = global; + each(value.split('.'), function (part) { + g = g[part]; + }); + return g; + } + + /** + * Constructs an error with a pointer to an URL with more information. + * @param {String} id the error ID that maps to an ID on a web page. + * @param {String} message human readable error. + * @param {Error} [err] the original error, if there is one. + * + * @returns {Error} + */ + function makeError(id, msg, err, requireModules) { + var e = new Error(msg + '\nhttp://requirejs.org/docs/errors.html#' + id); + e.requireType = id; + e.requireModules = requireModules; + if (err) { + e.originalError = err; + } + return e; + } + + if (typeof define !== 'undefined') { + //If a define is already in play via another AMD loader, + //do not overwrite. + return; + } + + if (typeof requirejs !== 'undefined') { + if (isFunction(requirejs)) { + //Do not overwrite an existing requirejs instance. + return; + } + cfg = requirejs; + requirejs = undefined; + } + + //Allow for a require config object + if (typeof require !== 'undefined' && !isFunction(require)) { + //assume it is a config object. + cfg = require; + require = undefined; + } + + function newContext(contextName) { + var inCheckLoaded, Module, context, handlers, + checkLoadedTimeoutId, + config = { + //Defaults. Do not set a default for map + //config to speed up normalize(), which + //will run faster if there is no default. + waitSeconds: 7, + baseUrl: './', + paths: {}, + bundles: {}, + pkgs: {}, + shim: {}, + config: {} + }, + registry = {}, + //registry of just enabled modules, to speed + //cycle breaking code when lots of modules + //are registered, but not activated. + enabledRegistry = {}, + undefEvents = {}, + defQueue = [], + defined = {}, + urlFetched = {}, + bundlesMap = {}, + requireCounter = 1, + unnormalizedCounter = 1; + + /** + * Trims the . and .. from an array of path segments. + * It will keep a leading path segment if a .. will become + * the first path segment, to help with module name lookups, + * which act like paths, but can be remapped. But the end result, + * all paths that use this function should look normalized. + * NOTE: this method MODIFIES the input array. + * @param {Array} ary the array of path segments. + */ + function trimDots(ary) { + var i, part; + for (i = 0; i < ary.length; i++) { + part = ary[i]; + if (part === '.') { + ary.splice(i, 1); + i -= 1; + } else if (part === '..') { + // If at the start, or previous value is still .., + // keep them so that when converted to a path it may + // still work when converted to a path, even though + // as an ID it is less than ideal. In larger point + // releases, may be better to just kick out an error. + if (i === 0 || (i == 1 && ary[2] === '..') || ary[i - 1] === '..') { + continue; + } else if (i > 0) { + ary.splice(i - 1, 2); + i -= 2; + } + } + } + } + + /** + * Given a relative module name, like ./something, normalize it to + * a real name that can be mapped to a path. + * @param {String} name the relative name + * @param {String} baseName a real name that the name arg is relative + * to. + * @param {Boolean} applyMap apply the map config to the value. Should + * only be done if this normalization is for a dependency ID. + * @returns {String} normalized name + */ + function normalize(name, baseName, applyMap) { + var pkgMain, mapValue, nameParts, i, j, nameSegment, lastIndex, + foundMap, foundI, foundStarMap, starI, normalizedBaseParts, + baseParts = (baseName && baseName.split('/')), + map = config.map, + starMap = map && map['*']; + + //Adjust any relative paths. + if (name) { + name = name.split('/'); + lastIndex = name.length - 1; + + // If wanting node ID compatibility, strip .js from end + // of IDs. Have to do this here, and not in nameToUrl + // because node allows either .js or non .js to map + // to same file. + if (config.nodeIdCompat && jsSuffixRegExp.test(name[lastIndex])) { + name[lastIndex] = name[lastIndex].replace(jsSuffixRegExp, ''); + } + + // Starts with a '.' so need the baseName + if (name[0].charAt(0) === '.' && baseParts) { + //Convert baseName to array, and lop off the last part, + //so that . matches that 'directory' and not name of the baseName's + //module. For instance, baseName of 'one/two/three', maps to + //'one/two/three.js', but we want the directory, 'one/two' for + //this normalization. + normalizedBaseParts = baseParts.slice(0, baseParts.length - 1); + name = normalizedBaseParts.concat(name); + } + + trimDots(name); + name = name.join('/'); + } + + //Apply map config if available. + if (applyMap && map && (baseParts || starMap)) { + nameParts = name.split('/'); + + outerLoop: for (i = nameParts.length; i > 0; i -= 1) { + nameSegment = nameParts.slice(0, i).join('/'); + + if (baseParts) { + //Find the longest baseName segment match in the config. + //So, do joins on the biggest to smallest lengths of baseParts. + for (j = baseParts.length; j > 0; j -= 1) { + mapValue = getOwn(map, baseParts.slice(0, j).join('/')); + + //baseName segment has config, find if it has one for + //this name. + if (mapValue) { + mapValue = getOwn(mapValue, nameSegment); + if (mapValue) { + //Match, update name to the new value. + foundMap = mapValue; + foundI = i; + break outerLoop; + } + } + } + } + + //Check for a star map match, but just hold on to it, + //if there is a shorter segment match later in a matching + //config, then favor over this star map. + if (!foundStarMap && starMap && getOwn(starMap, nameSegment)) { + foundStarMap = getOwn(starMap, nameSegment); + starI = i; + } + } + + if (!foundMap && foundStarMap) { + foundMap = foundStarMap; + foundI = starI; + } + + if (foundMap) { + nameParts.splice(0, foundI, foundMap); + name = nameParts.join('/'); + } + } + + // If the name points to a package's name, use + // the package main instead. + pkgMain = getOwn(config.pkgs, name); + + return pkgMain ? pkgMain : name; + } + + function removeScript(name) { + if (isBrowser) { + each(scripts(), function (scriptNode) { + if (scriptNode.getAttribute('data-requiremodule') === name && + scriptNode.getAttribute('data-requirecontext') === context.contextName) { + scriptNode.parentNode.removeChild(scriptNode); + return true; + } + }); + } + } + + function hasPathFallback(id) { + var pathConfig = getOwn(config.paths, id); + if (pathConfig && isArray(pathConfig) && pathConfig.length > 1) { + //Pop off the first array value, since it failed, and + //retry + pathConfig.shift(); + context.require.undef(id); + + //Custom require that does not do map translation, since + //ID is "absolute", already mapped/resolved. + context.makeRequire(null, { + skipMap: true + })([id]); + + return true; + } + } + + //Turns a plugin!resource to [plugin, resource] + //with the plugin being undefined if the name + //did not have a plugin prefix. + function splitPrefix(name) { + var prefix, + index = name ? name.indexOf('!') : -1; + if (index > -1) { + prefix = name.substring(0, index); + name = name.substring(index + 1, name.length); + } + return [prefix, name]; + } + + /** + * Creates a module mapping that includes plugin prefix, module + * name, and path. If parentModuleMap is provided it will + * also normalize the name via require.normalize() + * + * @param {String} name the module name + * @param {String} [parentModuleMap] parent module map + * for the module name, used to resolve relative names. + * @param {Boolean} isNormalized: is the ID already normalized. + * This is true if this call is done for a define() module ID. + * @param {Boolean} applyMap: apply the map config to the ID. + * Should only be true if this map is for a dependency. + * + * @returns {Object} + */ + function makeModuleMap(name, parentModuleMap, isNormalized, applyMap) { + var url, pluginModule, suffix, nameParts, + prefix = null, + parentName = parentModuleMap ? parentModuleMap.name : null, + originalName = name, + isDefine = true, + normalizedName = ''; + + //If no name, then it means it is a require call, generate an + //internal name. + if (!name) { + isDefine = false; + name = '_@r' + (requireCounter += 1); + } + + nameParts = splitPrefix(name); + prefix = nameParts[0]; + name = nameParts[1]; + + if (prefix) { + prefix = normalize(prefix, parentName, applyMap); + pluginModule = getOwn(defined, prefix); + } + + //Account for relative paths if there is a base name. + if (name) { + if (prefix) { + if (pluginModule && pluginModule.normalize) { + //Plugin is loaded, use its normalize method. + normalizedName = pluginModule.normalize(name, function (name) { + return normalize(name, parentName, applyMap); + }); + } else { + // If nested plugin references, then do not try to + // normalize, as it will not normalize correctly. This + // places a restriction on resourceIds, and the longer + // term solution is not to normalize until plugins are + // loaded and all normalizations to allow for async + // loading of a loader plugin. But for now, fixes the + // common uses. Details in #1131 + normalizedName = name.indexOf('!') === -1 ? + normalize(name, parentName, applyMap) : + name; + } + } else { + //A regular module. + normalizedName = normalize(name, parentName, applyMap); + + //Normalized name may be a plugin ID due to map config + //application in normalize. The map config values must + //already be normalized, so do not need to redo that part. + nameParts = splitPrefix(normalizedName); + prefix = nameParts[0]; + normalizedName = nameParts[1]; + isNormalized = true; + + url = context.nameToUrl(normalizedName); + } + } + + //If the id is a plugin id that cannot be determined if it needs + //normalization, stamp it with a unique ID so two matching relative + //ids that may conflict can be separate. + suffix = prefix && !pluginModule && !isNormalized ? + '_unnormalized' + (unnormalizedCounter += 1) : + ''; + + return { + prefix: prefix, + name: normalizedName, + parentMap: parentModuleMap, + unnormalized: !!suffix, + url: url, + originalName: originalName, + isDefine: isDefine, + id: (prefix ? + prefix + '!' + normalizedName : + normalizedName) + suffix + }; + } + + function getModule(depMap) { + var id = depMap.id, + mod = getOwn(registry, id); + + if (!mod) { + mod = registry[id] = new context.Module(depMap); + } + + return mod; + } + + function on(depMap, name, fn) { + var id = depMap.id, + mod = getOwn(registry, id); + + if (hasProp(defined, id) && + (!mod || mod.defineEmitComplete)) { + if (name === 'defined') { + fn(defined[id]); + } + } else { + mod = getModule(depMap); + if (mod.error && name === 'error') { + fn(mod.error); + } else { + mod.on(name, fn); + } + } + } + + function onError(err, errback) { + var ids = err.requireModules, + notified = false; + + if (errback) { + errback(err); + } else { + each(ids, function (id) { + var mod = getOwn(registry, id); + if (mod) { + //Set error on module, so it skips timeout checks. + mod.error = err; + if (mod.events.error) { + notified = true; + mod.emit('error', err); + } + } + }); + + if (!notified) { + req.onError(err); + } + } + } + + /** + * Internal method to transfer globalQueue items to this context's + * defQueue. + */ + function takeGlobalQueue() { + //Push all the globalDefQueue items into the context's defQueue + if (globalDefQueue.length) { + //Array splice in the values since the context code has a + //local var ref to defQueue, so cannot just reassign the one + //on context. + apsp.apply(defQueue, + [defQueue.length, 0].concat(globalDefQueue)); + globalDefQueue = []; + } + } + + handlers = { + 'require': function (mod) { + if (mod.require) { + return mod.require; + } else { + return (mod.require = context.makeRequire(mod.map)); + } + }, + 'exports': function (mod) { + mod.usingExports = true; + if (mod.map.isDefine) { + if (mod.exports) { + return (defined[mod.map.id] = mod.exports); + } else { + return (mod.exports = defined[mod.map.id] = {}); + } + } + }, + 'module': function (mod) { + if (mod.module) { + return mod.module; + } else { + return (mod.module = { + id: mod.map.id, + uri: mod.map.url, + config: function () { + return getOwn(config.config, mod.map.id) || {}; + }, + exports: mod.exports || (mod.exports = {}) + }); + } + } + }; + + function cleanRegistry(id) { + //Clean up machinery used for waiting modules. + delete registry[id]; + delete enabledRegistry[id]; + } + + function breakCycle(mod, traced, processed) { + var id = mod.map.id; + + if (mod.error) { + mod.emit('error', mod.error); + } else { + traced[id] = true; + each(mod.depMaps, function (depMap, i) { + var depId = depMap.id, + dep = getOwn(registry, depId); + + //Only force things that have not completed + //being defined, so still in the registry, + //and only if it has not been matched up + //in the module already. + if (dep && !mod.depMatched[i] && !processed[depId]) { + if (getOwn(traced, depId)) { + mod.defineDep(i, defined[depId]); + mod.check(); //pass false? + } else { + breakCycle(dep, traced, processed); + } + } + }); + processed[id] = true; + } + } + + function checkLoaded() { + var err, usingPathFallback, + waitInterval = config.waitSeconds * 1000, + //It is possible to disable the wait interval by using waitSeconds of 0. + expired = waitInterval && (context.startTime + waitInterval) < new Date().getTime(), + noLoads = [], + reqCalls = [], + stillLoading = false, + needCycleCheck = true; + + //Do not bother if this call was a result of a cycle break. + if (inCheckLoaded) { + return; + } + + inCheckLoaded = true; + + //Figure out the state of all the modules. + eachProp(enabledRegistry, function (mod) { + var map = mod.map, + modId = map.id; + + //Skip things that are not enabled or in error state. + if (!mod.enabled) { + return; + } + + if (!map.isDefine) { + reqCalls.push(mod); + } + + if (!mod.error) { + //If the module should be executed, and it has not + //been inited and time is up, remember it. + if (!mod.inited && expired) { + if (hasPathFallback(modId)) { + usingPathFallback = true; + stillLoading = true; + } else { + noLoads.push(modId); + removeScript(modId); + } + } else if (!mod.inited && mod.fetched && map.isDefine) { + stillLoading = true; + if (!map.prefix) { + //No reason to keep looking for unfinished + //loading. If the only stillLoading is a + //plugin resource though, keep going, + //because it may be that a plugin resource + //is waiting on a non-plugin cycle. + return (needCycleCheck = false); + } + } + } + }); + + if (expired && noLoads.length) { + //If wait time expired, throw error of unloaded modules. + err = makeError('timeout', 'Load timeout for modules: ' + noLoads, null, noLoads); + err.contextName = context.contextName; + return onError(err); + } + + //Not expired, check for a cycle. + if (needCycleCheck) { + each(reqCalls, function (mod) { + breakCycle(mod, {}, {}); + }); + } + + //If still waiting on loads, and the waiting load is something + //other than a plugin resource, or there are still outstanding + //scripts, then just try back later. + if ((!expired || usingPathFallback) && stillLoading) { + //Something is still waiting to load. Wait for it, but only + //if a timeout is not already in effect. + if ((isBrowser || isWebWorker) && !checkLoadedTimeoutId) { + checkLoadedTimeoutId = setTimeout(function () { + checkLoadedTimeoutId = 0; + checkLoaded(); + }, 50); + } + } + + inCheckLoaded = false; + } + + Module = function (map) { + this.events = getOwn(undefEvents, map.id) || {}; + this.map = map; + this.shim = getOwn(config.shim, map.id); + this.depExports = []; + this.depMaps = []; + this.depMatched = []; + this.pluginMaps = {}; + this.depCount = 0; + + /* this.exports this.factory + this.depMaps = [], + this.enabled, this.fetched + */ + }; + + Module.prototype = { + init: function (depMaps, factory, errback, options) { + options = options || {}; + + //Do not do more inits if already done. Can happen if there + //are multiple define calls for the same module. That is not + //a normal, common case, but it is also not unexpected. + if (this.inited) { + return; + } + + this.factory = factory; + + if (errback) { + //Register for errors on this module. + this.on('error', errback); + } else if (this.events.error) { + //If no errback already, but there are error listeners + //on this module, set up an errback to pass to the deps. + errback = bind(this, function (err) { + this.emit('error', err); + }); + } + + //Do a copy of the dependency array, so that + //source inputs are not modified. For example + //"shim" deps are passed in here directly, and + //doing a direct modification of the depMaps array + //would affect that config. + this.depMaps = depMaps && depMaps.slice(0); + + this.errback = errback; + + //Indicate this module has be initialized + this.inited = true; + + this.ignore = options.ignore; + + //Could have option to init this module in enabled mode, + //or could have been previously marked as enabled. However, + //the dependencies are not known until init is called. So + //if enabled previously, now trigger dependencies as enabled. + if (options.enabled || this.enabled) { + //Enable this module and dependencies. + //Will call this.check() + this.enable(); + } else { + this.check(); + } + }, + + defineDep: function (i, depExports) { + //Because of cycles, defined callback for a given + //export can be called more than once. + if (!this.depMatched[i]) { + this.depMatched[i] = true; + this.depCount -= 1; + this.depExports[i] = depExports; + } + }, + + fetch: function () { + if (this.fetched) { + return; + } + this.fetched = true; + + context.startTime = (new Date()).getTime(); + + var map = this.map; + + //If the manager is for a plugin managed resource, + //ask the plugin to load it now. + if (this.shim) { + context.makeRequire(this.map, { + enableBuildCallback: true + })(this.shim.deps || [], bind(this, function () { + return map.prefix ? this.callPlugin() : this.load(); + })); + } else { + //Regular dependency. + return map.prefix ? this.callPlugin() : this.load(); + } + }, + + load: function () { + var url = this.map.url; + + //Regular dependency. + if (!urlFetched[url]) { + urlFetched[url] = true; + context.load(this.map.id, url); + } + }, + + /** + * Checks if the module is ready to define itself, and if so, + * define it. + */ + check: function () { + if (!this.enabled || this.enabling) { + return; + } + + var err, cjsModule, + id = this.map.id, + depExports = this.depExports, + exports = this.exports, + factory = this.factory; + + if (!this.inited) { + this.fetch(); + } else if (this.error) { + this.emit('error', this.error); + } else if (!this.defining) { + //The factory could trigger another require call + //that would result in checking this module to + //define itself again. If already in the process + //of doing that, skip this work. + this.defining = true; + + if (this.depCount < 1 && !this.defined) { + if (isFunction(factory)) { + //If there is an error listener, favor passing + //to that instead of throwing an error. However, + //only do it for define()'d modules. require + //errbacks should not be called for failures in + //their callbacks (#699). However if a global + //onError is set, use that. + if ((this.events.error && this.map.isDefine) || + req.onError !== defaultOnError) { + try { + exports = context.execCb(id, factory, depExports, exports); + } catch (e) { + err = e; + } + } else { + exports = context.execCb(id, factory, depExports, exports); + } + + // Favor return value over exports. If node/cjs in play, + // then will not have a return value anyway. Favor + // module.exports assignment over exports object. + if (this.map.isDefine && exports === undefined) { + cjsModule = this.module; + if (cjsModule) { + exports = cjsModule.exports; + } else if (this.usingExports) { + //exports already set the defined value. + exports = this.exports; + } + } + + if (err) { + err.requireMap = this.map; + err.requireModules = this.map.isDefine ? [this.map.id] : null; + err.requireType = this.map.isDefine ? 'define' : 'require'; + return onError((this.error = err)); + } + + } else { + //Just a literal value + exports = factory; + } + + this.exports = exports; + + if (this.map.isDefine && !this.ignore) { + defined[id] = exports; + + if (req.onResourceLoad) { + req.onResourceLoad(context, this.map, this.depMaps); + } + } + + //Clean up + cleanRegistry(id); + + this.defined = true; + } + + //Finished the define stage. Allow calling check again + //to allow define notifications below in the case of a + //cycle. + this.defining = false; + + if (this.defined && !this.defineEmitted) { + this.defineEmitted = true; + this.emit('defined', this.exports); + this.defineEmitComplete = true; + } + + } + }, + + callPlugin: function () { + var map = this.map, + id = map.id, + //Map already normalized the prefix. + pluginMap = makeModuleMap(map.prefix); + + //Mark this as a dependency for this plugin, so it + //can be traced for cycles. + this.depMaps.push(pluginMap); + + on(pluginMap, 'defined', bind(this, function (plugin) { + var load, normalizedMap, normalizedMod, + bundleId = getOwn(bundlesMap, this.map.id), + name = this.map.name, + parentName = this.map.parentMap ? this.map.parentMap.name : null, + localRequire = context.makeRequire(map.parentMap, { + enableBuildCallback: true + }); + + //If current map is not normalized, wait for that + //normalized name to load instead of continuing. + if (this.map.unnormalized) { + //Normalize the ID if the plugin allows it. + if (plugin.normalize) { + name = plugin.normalize(name, function (name) { + return normalize(name, parentName, true); + }) || ''; + } + + //prefix and name should already be normalized, no need + //for applying map config again either. + normalizedMap = makeModuleMap(map.prefix + '!' + name, + this.map.parentMap); + on(normalizedMap, + 'defined', bind(this, function (value) { + this.init([], function () { return value; }, null, { + enabled: true, + ignore: true + }); + })); + + normalizedMod = getOwn(registry, normalizedMap.id); + if (normalizedMod) { + //Mark this as a dependency for this plugin, so it + //can be traced for cycles. + this.depMaps.push(normalizedMap); + + if (this.events.error) { + normalizedMod.on('error', bind(this, function (err) { + this.emit('error', err); + })); + } + normalizedMod.enable(); + } + + return; + } + + //If a paths config, then just load that file instead to + //resolve the plugin, as it is built into that paths layer. + if (bundleId) { + this.map.url = context.nameToUrl(bundleId); + this.load(); + return; + } + + load = bind(this, function (value) { + this.init([], function () { return value; }, null, { + enabled: true + }); + }); + + load.error = bind(this, function (err) { + this.inited = true; + this.error = err; + err.requireModules = [id]; + + //Remove temp unnormalized modules for this module, + //since they will never be resolved otherwise now. + eachProp(registry, function (mod) { + if (mod.map.id.indexOf(id + '_unnormalized') === 0) { + cleanRegistry(mod.map.id); + } + }); + + onError(err); + }); + + //Allow plugins to load other code without having to know the + //context or how to 'complete' the load. + load.fromText = bind(this, function (text, textAlt) { + /*jslint evil: true */ + var moduleName = map.name, + moduleMap = makeModuleMap(moduleName), + hasInteractive = useInteractive; + + //As of 2.1.0, support just passing the text, to reinforce + //fromText only being called once per resource. Still + //support old style of passing moduleName but discard + //that moduleName in favor of the internal ref. + if (textAlt) { + text = textAlt; + } + + //Turn off interactive script matching for IE for any define + //calls in the text, then turn it back on at the end. + if (hasInteractive) { + useInteractive = false; + } + + //Prime the system by creating a module instance for + //it. + getModule(moduleMap); + + //Transfer any config to this other module. + if (hasProp(config.config, id)) { + config.config[moduleName] = config.config[id]; + } + + try { + req.exec(text); + } catch (e) { + return onError(makeError('fromtexteval', + 'fromText eval for ' + id + + ' failed: ' + e, + e, + [id])); + } + + if (hasInteractive) { + useInteractive = true; + } + + //Mark this as a dependency for the plugin + //resource + this.depMaps.push(moduleMap); + + //Support anonymous modules. + context.completeLoad(moduleName); + + //Bind the value of that module to the value for this + //resource ID. + localRequire([moduleName], load); + }); + + //Use parentName here since the plugin's name is not reliable, + //could be some weird string with no path that actually wants to + //reference the parentName's path. + plugin.load(map.name, localRequire, load, config); + })); + + context.enable(pluginMap, this); + this.pluginMaps[pluginMap.id] = pluginMap; + }, + + enable: function () { + enabledRegistry[this.map.id] = this; + this.enabled = true; + + //Set flag mentioning that the module is enabling, + //so that immediate calls to the defined callbacks + //for dependencies do not trigger inadvertent load + //with the depCount still being zero. + this.enabling = true; + + //Enable each dependency + each(this.depMaps, bind(this, function (depMap, i) { + var id, mod, handler; + + if (typeof depMap === 'string') { + //Dependency needs to be converted to a depMap + //and wired up to this module. + depMap = makeModuleMap(depMap, + (this.map.isDefine ? this.map : this.map.parentMap), + false, + !this.skipMap); + this.depMaps[i] = depMap; + + handler = getOwn(handlers, depMap.id); + + if (handler) { + this.depExports[i] = handler(this); + return; + } + + this.depCount += 1; + + on(depMap, 'defined', bind(this, function (depExports) { + this.defineDep(i, depExports); + this.check(); + })); + + if (this.errback) { + on(depMap, 'error', bind(this, this.errback)); + } else if (this.events.error) { + // No direct errback on this module, but something + // else is listening for errors, so be sure to + // propagate the error correctly. + on(depMap, 'error', bind(this, function(err) { + this.emit('error', err); + })); + } + } + + id = depMap.id; + mod = registry[id]; + + //Skip special modules like 'require', 'exports', 'module' + //Also, don't call enable if it is already enabled, + //important in circular dependency cases. + if (!hasProp(handlers, id) && mod && !mod.enabled) { + context.enable(depMap, this); + } + })); + + //Enable each plugin that is used in + //a dependency + eachProp(this.pluginMaps, bind(this, function (pluginMap) { + var mod = getOwn(registry, pluginMap.id); + if (mod && !mod.enabled) { + context.enable(pluginMap, this); + } + })); + + this.enabling = false; + + this.check(); + }, + + on: function (name, cb) { + var cbs = this.events[name]; + if (!cbs) { + cbs = this.events[name] = []; + } + cbs.push(cb); + }, + + emit: function (name, evt) { + each(this.events[name], function (cb) { + cb(evt); + }); + if (name === 'error') { + //Now that the error handler was triggered, remove + //the listeners, since this broken Module instance + //can stay around for a while in the registry. + delete this.events[name]; + } + } + }; + + function callGetModule(args) { + //Skip modules already defined. + if (!hasProp(defined, args[0])) { + getModule(makeModuleMap(args[0], null, true)).init(args[1], args[2]); + } + } + + function removeListener(node, func, name, ieName) { + //Favor detachEvent because of IE9 + //issue, see attachEvent/addEventListener comment elsewhere + //in this file. + if (node.detachEvent && !isOpera) { + //Probably IE. If not it will throw an error, which will be + //useful to know. + if (ieName) { + node.detachEvent(ieName, func); + } + } else { + node.removeEventListener(name, func, false); + } + } + + /** + * Given an event from a script node, get the requirejs info from it, + * and then removes the event listeners on the node. + * @param {Event} evt + * @returns {Object} + */ + function getScriptData(evt) { + //Using currentTarget instead of target for Firefox 2.0's sake. Not + //all old browsers will be supported, but this one was easy enough + //to support and still makes sense. + var node = evt.currentTarget || evt.srcElement; + + //Remove the listeners once here. + removeListener(node, context.onScriptLoad, 'load', 'onreadystatechange'); + removeListener(node, context.onScriptError, 'error'); + + return { + node: node, + id: node && node.getAttribute('data-requiremodule') + }; + } + + function intakeDefines() { + var args; + + //Any defined modules in the global queue, intake them now. + takeGlobalQueue(); + + //Make sure any remaining defQueue items get properly processed. + while (defQueue.length) { + args = defQueue.shift(); + if (args[0] === null) { + return onError(makeError('mismatch', 'Mismatched anonymous define() module: ' + args[args.length - 1])); + } else { + //args are id, deps, factory. Should be normalized by the + //define() function. + callGetModule(args); + } + } + } + + context = { + config: config, + contextName: contextName, + registry: registry, + defined: defined, + urlFetched: urlFetched, + defQueue: defQueue, + Module: Module, + makeModuleMap: makeModuleMap, + nextTick: req.nextTick, + onError: onError, + + /** + * Set a configuration for the context. + * @param {Object} cfg config object to integrate. + */ + configure: function (cfg) { + //Make sure the baseUrl ends in a slash. + if (cfg.baseUrl) { + if (cfg.baseUrl.charAt(cfg.baseUrl.length - 1) !== '/') { + cfg.baseUrl += '/'; + } + } + + //Save off the paths since they require special processing, + //they are additive. + var shim = config.shim, + objs = { + paths: true, + bundles: true, + config: true, + map: true + }; + + eachProp(cfg, function (value, prop) { + if (objs[prop]) { + if (!config[prop]) { + config[prop] = {}; + } + mixin(config[prop], value, true, true); + } else { + config[prop] = value; + } + }); + + //Reverse map the bundles + if (cfg.bundles) { + eachProp(cfg.bundles, function (value, prop) { + each(value, function (v) { + if (v !== prop) { + bundlesMap[v] = prop; + } + }); + }); + } + + //Merge shim + if (cfg.shim) { + eachProp(cfg.shim, function (value, id) { + //Normalize the structure + if (isArray(value)) { + value = { + deps: value + }; + } + if ((value.exports || value.init) && !value.exportsFn) { + value.exportsFn = context.makeShimExports(value); + } + shim[id] = value; + }); + config.shim = shim; + } + + //Adjust packages if necessary. + if (cfg.packages) { + each(cfg.packages, function (pkgObj) { + var location, name; + + pkgObj = typeof pkgObj === 'string' ? { name: pkgObj } : pkgObj; + + name = pkgObj.name; + location = pkgObj.location; + if (location) { + config.paths[name] = pkgObj.location; + } + + //Save pointer to main module ID for pkg name. + //Remove leading dot in main, so main paths are normalized, + //and remove any trailing .js, since different package + //envs have different conventions: some use a module name, + //some use a file name. + config.pkgs[name] = pkgObj.name + '/' + (pkgObj.main || 'main') + .replace(currDirRegExp, '') + .replace(jsSuffixRegExp, ''); + }); + } + + //If there are any "waiting to execute" modules in the registry, + //update the maps for them, since their info, like URLs to load, + //may have changed. + eachProp(registry, function (mod, id) { + //If module already has init called, since it is too + //late to modify them, and ignore unnormalized ones + //since they are transient. + if (!mod.inited && !mod.map.unnormalized) { + mod.map = makeModuleMap(id); + } + }); + + //If a deps array or a config callback is specified, then call + //require with those args. This is useful when require is defined as a + //config object before require.js is loaded. + if (cfg.deps || cfg.callback) { + context.require(cfg.deps || [], cfg.callback); + } + }, + + makeShimExports: function (value) { + function fn() { + var ret; + if (value.init) { + ret = value.init.apply(global, arguments); + } + return ret || (value.exports && getGlobal(value.exports)); + } + return fn; + }, + + makeRequire: function (relMap, options) { + options = options || {}; + + function localRequire(deps, callback, errback) { + var id, map, requireMod; + + if (options.enableBuildCallback && callback && isFunction(callback)) { + callback.__requireJsBuild = true; + } + + if (typeof deps === 'string') { + if (isFunction(callback)) { + //Invalid call + return onError(makeError('requireargs', 'Invalid require call'), errback); + } + + //If require|exports|module are requested, get the + //value for them from the special handlers. Caveat: + //this only works while module is being defined. + if (relMap && hasProp(handlers, deps)) { + return handlers[deps](registry[relMap.id]); + } + + //Synchronous access to one module. If require.get is + //available (as in the Node adapter), prefer that. + if (req.get) { + return req.get(context, deps, relMap, localRequire); + } + + //Normalize module name, if it contains . or .. + map = makeModuleMap(deps, relMap, false, true); + id = map.id; + + if (!hasProp(defined, id)) { + return onError(makeError('notloaded', 'Module name "' + + id + + '" has not been loaded yet for context: ' + + contextName + + (relMap ? '' : '. Use require([])'))); + } + return defined[id]; + } + + //Grab defines waiting in the global queue. + intakeDefines(); + + //Mark all the dependencies as needing to be loaded. + context.nextTick(function () { + //Some defines could have been added since the + //require call, collect them. + intakeDefines(); + + requireMod = getModule(makeModuleMap(null, relMap)); + + //Store if map config should be applied to this require + //call for dependencies. + requireMod.skipMap = options.skipMap; + + requireMod.init(deps, callback, errback, { + enabled: true + }); + + checkLoaded(); + }); + + return localRequire; + } + + mixin(localRequire, { + isBrowser: isBrowser, + + /** + * Converts a module name + .extension into an URL path. + * *Requires* the use of a module name. It does not support using + * plain URLs like nameToUrl. + */ + toUrl: function (moduleNamePlusExt) { + var ext, + index = moduleNamePlusExt.lastIndexOf('.'), + segment = moduleNamePlusExt.split('/')[0], + isRelative = segment === '.' || segment === '..'; + + //Have a file extension alias, and it is not the + //dots from a relative path. + if (index !== -1 && (!isRelative || index > 1)) { + ext = moduleNamePlusExt.substring(index, moduleNamePlusExt.length); + moduleNamePlusExt = moduleNamePlusExt.substring(0, index); + } + + return context.nameToUrl(normalize(moduleNamePlusExt, + relMap && relMap.id, true), ext, true); + }, + + defined: function (id) { + return hasProp(defined, makeModuleMap(id, relMap, false, true).id); + }, + + specified: function (id) { + id = makeModuleMap(id, relMap, false, true).id; + return hasProp(defined, id) || hasProp(registry, id); + } + }); + + //Only allow undef on top level require calls + if (!relMap) { + localRequire.undef = function (id) { + //Bind any waiting define() calls to this context, + //fix for #408 + takeGlobalQueue(); + + var map = makeModuleMap(id, relMap, true), + mod = getOwn(registry, id); + + removeScript(id); + + delete defined[id]; + delete urlFetched[map.url]; + delete undefEvents[id]; + + //Clean queued defines too. Go backwards + //in array so that the splices do not + //mess up the iteration. + eachReverse(defQueue, function(args, i) { + if(args[0] === id) { + defQueue.splice(i, 1); + } + }); + + if (mod) { + //Hold on to listeners in case the + //module will be attempted to be reloaded + //using a different config. + if (mod.events.defined) { + undefEvents[id] = mod.events; + } + + cleanRegistry(id); + } + }; + } + + return localRequire; + }, + + /** + * Called to enable a module if it is still in the registry + * awaiting enablement. A second arg, parent, the parent module, + * is passed in for context, when this method is overridden by + * the optimizer. Not shown here to keep code compact. + */ + enable: function (depMap) { + var mod = getOwn(registry, depMap.id); + if (mod) { + getModule(depMap).enable(); + } + }, + + /** + * Internal method used by environment adapters to complete a load event. + * A load event could be a script load or just a load pass from a synchronous + * load call. + * @param {String} moduleName the name of the module to potentially complete. + */ + completeLoad: function (moduleName) { + var found, args, mod, + shim = getOwn(config.shim, moduleName) || {}, + shExports = shim.exports; + + takeGlobalQueue(); + + while (defQueue.length) { + args = defQueue.shift(); + if (args[0] === null) { + args[0] = moduleName; + //If already found an anonymous module and bound it + //to this name, then this is some other anon module + //waiting for its completeLoad to fire. + if (found) { + break; + } + found = true; + } else if (args[0] === moduleName) { + //Found matching define call for this script! + found = true; + } + + callGetModule(args); + } + + //Do this after the cycle of callGetModule in case the result + //of those calls/init calls changes the registry. + mod = getOwn(registry, moduleName); + + if (!found && !hasProp(defined, moduleName) && mod && !mod.inited) { + if (config.enforceDefine && (!shExports || !getGlobal(shExports))) { + if (hasPathFallback(moduleName)) { + return; + } else { + return onError(makeError('nodefine', + 'No define call for ' + moduleName, + null, + [moduleName])); + } + } else { + //A script that does not call define(), so just simulate + //the call for it. + callGetModule([moduleName, (shim.deps || []), shim.exportsFn]); + } + } + + checkLoaded(); + }, + + /** + * Converts a module name to a file path. Supports cases where + * moduleName may actually be just an URL. + * Note that it **does not** call normalize on the moduleName, + * it is assumed to have already been normalized. This is an + * internal API, not a public one. Use toUrl for the public API. + */ + nameToUrl: function (moduleName, ext, skipExt) { + var paths, syms, i, parentModule, url, + parentPath, bundleId, + pkgMain = getOwn(config.pkgs, moduleName); + + if (pkgMain) { + moduleName = pkgMain; + } + + bundleId = getOwn(bundlesMap, moduleName); + + if (bundleId) { + return context.nameToUrl(bundleId, ext, skipExt); + } + + //If a colon is in the URL, it indicates a protocol is used and it is just + //an URL to a file, or if it starts with a slash, contains a query arg (i.e. ?) + //or ends with .js, then assume the user meant to use an url and not a module id. + //The slash is important for protocol-less URLs as well as full paths. + if (req.jsExtRegExp.test(moduleName)) { + //Just a plain path, not module name lookup, so just return it. + //Add extension if it is included. This is a bit wonky, only non-.js things pass + //an extension, this method probably needs to be reworked. + url = moduleName + (ext || ''); + } else { + //A module that needs to be converted to a path. + paths = config.paths; + + syms = moduleName.split('/'); + //For each module name segment, see if there is a path + //registered for it. Start with most specific name + //and work up from it. + for (i = syms.length; i > 0; i -= 1) { + parentModule = syms.slice(0, i).join('/'); + + parentPath = getOwn(paths, parentModule); + if (parentPath) { + //If an array, it means there are a few choices, + //Choose the one that is desired + if (isArray(parentPath)) { + parentPath = parentPath[0]; + } + syms.splice(0, i, parentPath); + break; + } + } + + //Join the path parts together, then figure out if baseUrl is needed. + url = syms.join('/'); + url += (ext || (/^data\:|\?/.test(url) || skipExt ? '' : '.js')); + url = (url.charAt(0) === '/' || url.match(/^[\w\+\.\-]+:/) ? '' : config.baseUrl) + url; + } + + return config.urlArgs ? url + + ((url.indexOf('?') === -1 ? '?' : '&') + + config.urlArgs) : url; + }, + + //Delegates to req.load. Broken out as a separate function to + //allow overriding in the optimizer. + load: function (id, url) { + req.load(context, id, url); + }, + + /** + * Executes a module callback function. Broken out as a separate function + * solely to allow the build system to sequence the files in the built + * layer in the right sequence. + * + * @private + */ + execCb: function (name, callback, args, exports) { + return callback.apply(exports, args); + }, + + /** + * callback for script loads, used to check status of loading. + * + * @param {Event} evt the event from the browser for the script + * that was loaded. + */ + onScriptLoad: function (evt) { + //Using currentTarget instead of target for Firefox 2.0's sake. Not + //all old browsers will be supported, but this one was easy enough + //to support and still makes sense. + if (evt.type === 'load' || + (readyRegExp.test((evt.currentTarget || evt.srcElement).readyState))) { + //Reset interactive script so a script node is not held onto for + //to long. + interactiveScript = null; + + //Pull out the name of the module and the context. + var data = getScriptData(evt); + context.completeLoad(data.id); + } + }, + + /** + * Callback for script errors. + */ + onScriptError: function (evt) { + var data = getScriptData(evt); + if (!hasPathFallback(data.id)) { + return onError(makeError('scripterror', 'Script error for: ' + data.id, evt, [data.id])); + } + } + }; + + context.require = context.makeRequire(); + return context; + } + + /** + * Main entry point. + * + * If the only argument to require is a string, then the module that + * is represented by that string is fetched for the appropriate context. + * + * If the first argument is an array, then it will be treated as an array + * of dependency string names to fetch. An optional function callback can + * be specified to execute when all of those dependencies are available. + * + * Make a local req variable to help Caja compliance (it assumes things + * on a require that are not standardized), and to give a short + * name for minification/local scope use. + */ + req = requirejs = function (deps, callback, errback, optional) { + + //Find the right context, use default + var context, config, + contextName = defContextName; + + // Determine if have config object in the call. + if (!isArray(deps) && typeof deps !== 'string') { + // deps is a config object + config = deps; + if (isArray(callback)) { + // Adjust args if there are dependencies + deps = callback; + callback = errback; + errback = optional; + } else { + deps = []; + } + } + + if (config && config.context) { + contextName = config.context; + } + + context = getOwn(contexts, contextName); + if (!context) { + context = contexts[contextName] = req.s.newContext(contextName); + } + + if (config) { + context.configure(config); + } + + return context.require(deps, callback, errback); + }; + + /** + * Support require.config() to make it easier to cooperate with other + * AMD loaders on globally agreed names. + */ + req.config = function (config) { + return req(config); + }; + + /** + * Execute something after the current tick + * of the event loop. Override for other envs + * that have a better solution than setTimeout. + * @param {Function} fn function to execute later. + */ + req.nextTick = typeof setTimeout !== 'undefined' ? function (fn) { + setTimeout(fn, 4); + } : function (fn) { fn(); }; + + /** + * Export require as a global, but only if it does not already exist. + */ + if (!require) { + require = req; + } + + req.version = version; + + //Used to filter out dependencies that are already paths. + req.jsExtRegExp = /^\/|:|\?|\.js$/; + req.isBrowser = isBrowser; + s = req.s = { + contexts: contexts, + newContext: newContext + }; + + //Create default context. + req({}); + + //Exports some context-sensitive methods on global require. + each([ + 'toUrl', + 'undef', + 'defined', + 'specified' + ], function (prop) { + //Reference from contexts instead of early binding to default context, + //so that during builds, the latest instance of the default context + //with its config gets used. + req[prop] = function () { + var ctx = contexts[defContextName]; + return ctx.require[prop].apply(ctx, arguments); + }; + }); + + if (isBrowser) { + head = s.head = document.getElementsByTagName('head')[0]; + //If BASE tag is in play, using appendChild is a problem for IE6. + //When that browser dies, this can be removed. Details in this jQuery bug: + //http://dev.jquery.com/ticket/2709 + baseElement = document.getElementsByTagName('base')[0]; + if (baseElement) { + head = s.head = baseElement.parentNode; + } + } + + /** + * Any errors that require explicitly generates will be passed to this + * function. Intercept/override it if you want custom error handling. + * @param {Error} err the error object. + */ + req.onError = defaultOnError; + + /** + * Creates the node for the load command. Only used in browser envs. + */ + req.createNode = function (config, moduleName, url) { + var node = config.xhtml ? + document.createElementNS('http://www.w3.org/1999/xhtml', 'html:script') : + document.createElement('script'); + node.type = config.scriptType || 'text/javascript'; + node.charset = 'utf-8'; + node.async = true; + return node; + }; + + /** + * Does the request to load a module for the browser case. + * Make this a separate function to allow other environments + * to override it. + * + * @param {Object} context the require context to find state. + * @param {String} moduleName the name of the module. + * @param {Object} url the URL to the module. + */ + req.load = function (context, moduleName, url) { + var config = (context && context.config) || {}, + node; + if (isBrowser) { + //In the browser so use a script tag + node = req.createNode(config, moduleName, url); + + node.setAttribute('data-requirecontext', context.contextName); + node.setAttribute('data-requiremodule', moduleName); + + //Set up load listener. Test attachEvent first because IE9 has + //a subtle issue in its addEventListener and script onload firings + //that do not match the behavior of all other browsers with + //addEventListener support, which fire the onload event for a + //script right after the script execution. See: + //https://connect.microsoft.com/IE/feedback/details/648057/script-onload-event-is-not-fired-immediately-after-script-execution + //UNFORTUNATELY Opera implements attachEvent but does not follow the script + //script execution mode. + if (node.attachEvent && + //Check if node.attachEvent is artificially added by custom script or + //natively supported by browser + //read https://github.com/jrburke/requirejs/issues/187 + //if we can NOT find [native code] then it must NOT natively supported. + //in IE8, node.attachEvent does not have toString() + //Note the test for "[native code" with no closing brace, see: + //https://github.com/jrburke/requirejs/issues/273 + !(node.attachEvent.toString && node.attachEvent.toString().indexOf('[native code') < 0) && + !isOpera) { + //Probably IE. IE (at least 6-8) do not fire + //script onload right after executing the script, so + //we cannot tie the anonymous define call to a name. + //However, IE reports the script as being in 'interactive' + //readyState at the time of the define call. + useInteractive = true; + + node.attachEvent('onreadystatechange', context.onScriptLoad); + //It would be great to add an error handler here to catch + //404s in IE9+. However, onreadystatechange will fire before + //the error handler, so that does not help. If addEventListener + //is used, then IE will fire error before load, but we cannot + //use that pathway given the connect.microsoft.com issue + //mentioned above about not doing the 'script execute, + //then fire the script load event listener before execute + //next script' that other browsers do. + //Best hope: IE10 fixes the issues, + //and then destroys all installs of IE 6-9. + //node.attachEvent('onerror', context.onScriptError); + } else { + node.addEventListener('load', context.onScriptLoad, false); + node.addEventListener('error', context.onScriptError, false); + } + node.src = url; + + //For some cache cases in IE 6-8, the script executes before the end + //of the appendChild execution, so to tie an anonymous define + //call to the module name (which is stored on the node), hold on + //to a reference to this node, but clear after the DOM insertion. + currentlyAddingScript = node; + if (baseElement) { + head.insertBefore(node, baseElement); + } else { + head.appendChild(node); + } + currentlyAddingScript = null; + + return node; + } else if (isWebWorker) { + try { + //In a web worker, use importScripts. This is not a very + //efficient use of importScripts, importScripts will block until + //its script is downloaded and evaluated. However, if web workers + //are in play, the expectation that a build has been done so that + //only one script needs to be loaded anyway. This may need to be + //reevaluated if other use cases become common. + importScripts(url); + + //Account for anonymous modules + context.completeLoad(moduleName); + } catch (e) { + context.onError(makeError('importscripts', + 'importScripts failed for ' + + moduleName + ' at ' + url, + e, + [moduleName])); + } + } + }; + + function getInteractiveScript() { + if (interactiveScript && interactiveScript.readyState === 'interactive') { + return interactiveScript; + } + + eachReverse(scripts(), function (script) { + if (script.readyState === 'interactive') { + return (interactiveScript = script); + } + }); + return interactiveScript; + } + + //Look for a data-main script attribute, which could also adjust the baseUrl. + if (isBrowser && !cfg.skipDataMain) { + //Figure out baseUrl. Get it from the script tag with require.js in it. + eachReverse(scripts(), function (script) { + //Set the 'head' where we can append children by + //using the script's parent. + if (!head) { + head = script.parentNode; + } + + //Look for a data-main attribute to set main script for the page + //to load. If it is there, the path to data main becomes the + //baseUrl, if it is not already set. + dataMain = script.getAttribute('data-main'); + if (dataMain) { + //Preserve dataMain in case it is a path (i.e. contains '?') + mainScript = dataMain; + + //Set final baseUrl if there is not already an explicit one. + if (!cfg.baseUrl) { + //Pull off the directory of data-main for use as the + //baseUrl. + src = mainScript.split('/'); + mainScript = src.pop(); + subPath = src.length ? src.join('/') + '/' : './'; + + cfg.baseUrl = subPath; + } + + //Strip off any trailing .js since mainScript is now + //like a module name. + mainScript = mainScript.replace(jsSuffixRegExp, ''); + + //If mainScript is still a path, fall back to dataMain + if (req.jsExtRegExp.test(mainScript)) { + mainScript = dataMain; + } + + //Put the data-main script in the files to load. + cfg.deps = cfg.deps ? cfg.deps.concat(mainScript) : [mainScript]; + + return true; + } + }); + } + + /** + * The function that handles definitions of modules. Differs from + * require() in that a string for the module should be the first argument, + * and the function to execute after dependencies are loaded should + * return a value to define the module corresponding to the first argument's + * name. + */ + define = function (name, deps, callback) { + var node, context; + + //Allow for anonymous modules + if (typeof name !== 'string') { + //Adjust args appropriately + callback = deps; + deps = name; + name = null; + } + + //This module may not have dependencies + if (!isArray(deps)) { + callback = deps; + deps = null; + } + + //If no name, and callback is a function, then figure out if it a + //CommonJS thing with dependencies. + if (!deps && isFunction(callback)) { + deps = []; + //Remove comments from the callback string, + //look for require calls, and pull them into the dependencies, + //but only if there are function args. + if (callback.length) { + callback + .toString() + .replace(commentRegExp, '') + .replace(cjsRequireRegExp, function (match, dep) { + deps.push(dep); + }); + + //May be a CommonJS thing even without require calls, but still + //could use exports, and module. Avoid doing exports and module + //work though if it just needs require. + //REQUIRES the function to expect the CommonJS variables in the + //order listed below. + deps = (callback.length === 1 ? ['require'] : ['require', 'exports', 'module']).concat(deps); + } + } + + //If in IE 6-8 and hit an anonymous define() call, do the interactive + //work. + if (useInteractive) { + node = currentlyAddingScript || getInteractiveScript(); + if (node) { + if (!name) { + name = node.getAttribute('data-requiremodule'); + } + context = contexts[node.getAttribute('data-requirecontext')]; + } + } + + //Always save off evaluating the def call until the script onload handler. + //This allows multiple modules to be in a file without prematurely + //tracing dependencies, and allows for anonymous module support, + //where the module name is not known until the script onload event + //occurs. If no context, use the global queue, and get it processed + //in the onscript load callback. + (context ? context.defQueue : globalDefQueue).push([name, deps, callback]); + }; + + define.amd = { + jQuery: true + }; + + + /** + * Executes the text. Normally just uses eval, but can be modified + * to use a better, environment-specific call. Only used for transpiling + * loader plugins, not for plain JS modules. + * @param {String} text the text to execute/evaluate. + */ + req.exec = function (text) { + /*jslint evil: true */ + return eval(text); + }; + + //Set up with config info. + req(cfg); +}(this)); diff --git a/spec/js/specs.html b/spec/js/specs.html new file mode 100644 index 00000000..32ff06f1 --- /dev/null +++ b/spec/js/specs.html @@ -0,0 +1,49 @@ + + + + + Jasmine Spec Runner + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/js/specs_requirejs.html b/spec/js/specs_requirejs.html new file mode 100644 index 00000000..704936de --- /dev/null +++ b/spec/js/specs_requirejs.html @@ -0,0 +1,72 @@ + + + + + Jasmine Spec Runner + + + + + + + + + + + + + + + diff --git a/spec/js/translate.spec.js b/spec/js/translate.spec.js new file mode 100644 index 00000000..33d964ab --- /dev/null +++ b/spec/js/translate.spec.js @@ -0,0 +1,304 @@ +var I18n = require("../../app/assets/javascripts/i18n") + , Translations = require("./translations") +; + +describe("Translate", function(){ + var actual, expected; + + beforeEach(function(){ + I18n.reset(); + I18n.translations = Translations(); + }); + + it("sets bound alias", function() { + expect(I18n.t).toEqual(jasmine.any(Function)); + expect(I18n.t).not.toBe(I18n.translate); + }); + + it("returns translation for single scope", function(){ + expect(I18n.translate("hello")).toEqual("Hello World!"); + }); + + it("returns translation with 't' shortcut", function(){ + var t = I18n.t; + expect(t("hello")).toEqual("Hello World!"); + }); + + it("returns translation as object", function(){ + expect(I18n.translate("greetings")).toEqual(I18n.translations.en.greetings); + }); + + it("returns missing message translation for valid scope with null", function(){ + actual = I18n.translate("null_key"); + expected = '[missing "en.null_key" translation]'; + expect(actual).toEqual(expected); + }); + + it("returns missing message translation for invalid scope", function(){ + actual = I18n.translate("invalid.scope"); + expected = '[missing "en.invalid.scope" translation]'; + expect(actual).toEqual(expected); + }); + + it("returns missing message translation with provided locale for invalid scope", function(){ + actual = I18n.translate("invalid.scope", { locale: "ja" }); + expected = '[missing "ja.invalid.scope" translation]'; + expect(actual).toEqual(expected); + }); + + it("returns guessed translation if missingBehaviour is set to guess", function(){ + I18n.missingBehaviour = 'guess' + + var actual_1 = I18n.translate("invalid.thisIsAutomaticallyGeneratedTranslation"); + var expected_1 = 'this is automatically generated translation'; + expect(actual_1).toEqual(expected_1); + + var actual_2 = I18n.translate("invalid.this_is_automatically_generated_translation"); + var expected_2 = 'this is automatically generated translation'; + expect(actual_2).toEqual(expected_2); + }); + + it("returns guessed translation with prefix if missingBehaviour is set to guess and prefix is also provided", function(){ + I18n.missingBehaviour = 'guess' + I18n.missingTranslationPrefix = 'EE: ' + actual = I18n.translate("invalid.thisIsAutomaticallyGeneratedTranslation"); + expected = 'EE: this is automatically generated translation'; + expect(actual).toEqual(expected); + }); + + it("returns missing message translation for valid scope with scope", function(){ + actual = I18n.translate("monster", {scope: "greetings"}); + expected = '[missing "en.greetings.monster" translation]'; + expect(actual).toEqual(expected); + }); + + it("returns translation for single scope on a custom locale", function(){ + I18n.locale = "pt-BR"; + expect(I18n.translate("hello")).toEqual("Olá Mundo!"); + }); + + it("returns translation for multiple scopes", function(){ + expect(I18n.translate("greetings.stranger")).toEqual("Hello stranger!"); + }); + + it("returns translation with default locale option", function(){ + expect(I18n.translate("hello", {locale: "en"})).toEqual("Hello World!"); + expect(I18n.translate("hello", {locale: "pt-BR"})).toEqual("Olá Mundo!"); + }); + + it("fallbacks to the default locale when I18n.fallbacks is enabled", function(){ + I18n.locale = "pt-BR"; + I18n.fallbacks = true; + expect(I18n.translate("greetings.stranger")).toEqual("Hello stranger!"); + }); + + it("fallbacks to default locale when providing an unknown locale", function(){ + I18n.locale = "fr"; + I18n.fallbacks = true; + expect(I18n.translate("greetings.stranger")).toEqual("Hello stranger!"); + }); + + it("fallbacks to less specific locale", function(){ + I18n.locale = "de-DE"; + I18n.fallbacks = true; + expect(I18n.translate("hello")).toEqual("Hallo Welt!"); + }); + + describe("when a 3-part locale is used", function(){ + beforeEach(function(){ + I18n.locale = "zh-Hant-TW"; + I18n.fallbacks = true; + }); + + it("fallbacks to 2-part locale when absent", function(){ + expect(I18n.translate("cat")).toEqual("貓"); + }); + + it("fallbacks to 1-part locale when 2-part missing requested translation", function(){ + expect(I18n.translate("dog")).toEqual("狗"); + }); + + it("fallbacks to 2-part for the first time", function(){ + expect(I18n.translate("dragon")).toEqual("龍"); + }); + }); + + it("fallbacks using custom rules (function)", function(){ + I18n.locale = "no"; + I18n.fallbacks = true; + I18n.locales["no"] = function() { + return ["nb"]; + }; + + expect(I18n.translate("hello")).toEqual("Hei Verden!"); + }); + + it("fallbacks using custom rules (array)", function() { + I18n.locale = "no"; + I18n.fallbacks = true; + I18n.locales["no"] = ["no", "nb"]; + + expect(I18n.translate("hello")).toEqual("Hei Verden!"); + }); + + it("fallbacks using custom rules (string)", function() { + I18n.locale = "no"; + I18n.fallbacks = true; + I18n.locales["no"] = "nb"; + + expect(I18n.translate("hello")).toEqual("Hei Verden!"); + }); + + describe("when provided default values", function() { + it("uses scope provided in defaults if scope doesn't exist", function() { + actual = I18n.translate("Hello!", {defaults: [{scope: "greetings.stranger"}]}); + expect(actual).toEqual("Hello stranger!"); + }); + + it("continues to fallback until a scope is found", function() { + var defaults = [{scope: "foo"}, {scope: "hello"}]; + + actual = I18n.translate("foo", {defaults: defaults}); + expect(actual).toEqual("Hello World!"); + }); + + it("uses message if specified as a default", function() { + var defaults = [{message: "Hello all!"}]; + actual = I18n.translate("foo", {defaults: defaults}); + expect(actual).toEqual("Hello all!"); + }); + + it("uses the first message if no scopes are found", function() { + var defaults = [ + {scope: "bar"} + , {message: "Hello all!"} + , {scope: "hello"}]; + actual = I18n.translate("foo", {defaults: defaults}); + expect(actual).toEqual("Hello all!"); + }); + + it("uses default value if no scope is found", function() { + var options = { + defaults: [{scope: "bar"}] + , defaultValue: "Hello all!" + }; + actual = I18n.translate("foo", options); + expect(actual).toEqual("Hello all!"); + }); + + it("uses default scope over default value if default scope is found", function() { + var options = { + defaults: [{scope: "hello"}] + , defaultValue: "Hello all!" + }; + actual = I18n.translate("foo", options); + expect(actual).toEqual("Hello World!"); + }) + + it("uses default value with lazy evaluation", function () { + var options = { + defaults: [{scope: "bar"}] + , defaultValue: function(scope) { + return scope.toUpperCase(); + } + }; + actual = I18n.translate("foo", options); + expect(actual).toEqual("FOO"); + }) + + it("pluralizes using the correct scope if translation is found within default scope", function() { + expect(I18n.translations["en"]["mailbox"]).toEqual(undefined); + actual = I18n.translate("mailbox.inbox", {count: 1, defaults: [{scope: "inbox"}]}); + expected = I18n.translate("inbox", {count: 1}) + expect(actual).toEqual(expected) + }) + }); + + it("uses default value for simple translation", function(){ + actual = I18n.translate("warning", {defaultValue: "Warning!"}); + expect(actual).toEqual("Warning!"); + }); + + it("uses default value for plural translation", function(){ + actual = I18n.translate("message", {defaultValue: { one: '%{count} message', other: '%{count} messages'}, count: 1}); + expect(actual).toEqual("1 message"); + }); + + it("uses default value for unknown locale", function(){ + I18n.locale = "fr"; + actual = I18n.translate("warning", {defaultValue: "Warning!"}); + expect(actual).toEqual("Warning!"); + }); + + it("uses default value with interpolation", function(){ + actual = I18n.translate( + "alert", + {defaultValue: "Attention! {{message}}", message: "You're out of quota!"} + ); + + expect(actual).toEqual("Attention! You're out of quota!"); + }); + + it("ignores default value when scope exists", function(){ + actual = I18n.translate("hello", {defaultValue: "What's up?"}); + expect(actual).toEqual("Hello World!"); + }); + + it("returns translation for custom scope separator", function(){ + I18n.defaultSeparator = "•"; + actual = I18n.translate("greetings•stranger"); + expect(actual).toEqual("Hello stranger!"); + }); + + it("returns boolean values", function() { + expect(I18n.translate("booleans.yes")).toEqual(true); + expect(I18n.translate("booleans.no")).toEqual(false); + }); + + it("escapes $ when doing substitution (IE)", function(){ + I18n.locale = "en"; + + expect(I18n.translate("paid", {price: "$0"})).toEqual("You were paid $0"); + expect(I18n.translate("paid", {price: "$0.12"})).toEqual("You were paid $0.12"); + expect(I18n.translate("paid", {price: "$1.35"})).toEqual("You were paid $1.35"); + }); + + it("replaces all occurrences of escaped $", function(){ + I18n.locale = "en"; + + expect(I18n.translate("paid_with_vat", { + price: "$0.12", + vat: "$0.02"} + )).toEqual("You were paid $0.12 (incl. VAT $0.02)"); + }); + + it("sets default scope", function(){ + var options = {scope: "greetings"}; + expect(I18n.translate("stranger", options)).toEqual("Hello stranger!"); + }); + + it("accepts the scope as an array", function(){ + expect(I18n.translate(["greetings", "stranger"])).toEqual("Hello stranger!"); + }); + + it("accepts the scope as an array using a base scope", function(){ + expect(I18n.translate(["stranger"], {scope: "greetings"})).toEqual("Hello stranger!"); + }); + + it("returns an array with values interpolated", function(){ + var options = {value: 314}; + expect(I18n.translate("arrayWithParams", options)).toEqual([ + null, + "An item with a param of " + options.value, + "Another item with a param of " + options.value, + "A last item with a param of " + options.value, + ["An", "array", "of", "strings"], + {foo: "bar"} + ]); + }); + + + it("returns value with key containing dot but different separator specified", function() { + expect(I18n.t(["A implies B means something."], {scope: "sentences_with_dots", separator: "|"})).toEqual("A implies B means that when A is true, B must be true."); + }); +}); diff --git a/spec/js/translations.js b/spec/js/translations.js new file mode 100644 index 00000000..55ea2fb1 --- /dev/null +++ b/spec/js/translations.js @@ -0,0 +1,188 @@ + +;(function(){ + var generator = function() { + var Translations = {}; + + Translations.en = { + hello: "Hello World!" + , paid: "You were paid %{price}" + + , paid_with_vat: "You were paid %{price} (incl. VAT %{vat})" + + , booleans: { + yes: true, + no: false + } + + , greetings: { + stranger: "Hello stranger!" + , name: "Hello {{name}}!" + } + + , profile: { + details: "{{name}} is {{age}}-years old" + } + + , inbox: { + one: "You have {{count}} message" + , other: "You have {{count}} messages" + , zero: "You have no messages" + } + + , sent: { + one: null + , other: null + , zero: null + } + + , unread: { + one: "You have 1 new message ({{unread}} unread)" + , other: "You have {{count}} new messages ({{unread}} unread)" + , zero: "You have no new messages ({{unread}} unread)" + } + + , number: { + human: { + storage_units: { + units: { + "byte": { + one: "Byte" + , other: "Bytes" + } + , "kb": "KB" + , "mb": "MB" + , "gb": "GB" + , "tb": "TB" + } + } + } + } + + , extended: { + number: { + human: { + storage_units: { + units: { + "mb": "Megabyte" + } + } + } + } + } + + , arrayWithParams: [ + null, + "An item with a param of {{value}}", + "Another item with a param of {{value}}", + "A last item with a param of {{value}}", + ["An", "array", "of", "strings"], + {foo: "bar"} + ] + + , null_key: null, + + sentences_with_dots: { + "A implies B means something.": "A implies B means that when A is true, B must be true." + } + }; + + Translations["en-US"] = { + date: { + formats: { + "default": "%d/%m/%Y" + , "short": "%d de %B" + , "long": "%d de %B de %Y" + } + + , day_names: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + , abbr_day_names: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + , month_names: [null, "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] + , abbr_month_names: [null, "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"] + , meridian: ["am", "pm"] + } + }; + + Translations["pt-BR"] = { + hello: "Olá Mundo!" + + , number: { + currency: { + format: { + delimiter: ".", + format: "%u %n", + precision: 2, + separator: ",", + unit: "R$" + } + } + , percentage: { + format: { + delimiter: "" + , separator: "," + , precision: 2 + } + } + } + + , date: { + formats: { + "default": "%d/%m/%Y" + , "short": "%d de %B" + , "long": "%d de %B de %Y" + , "short_with_placeholders": "%d de %B {{p1}} {{p2}}" + } + , day_names: ["Domingo", "Segunda", "Terça", "Quarta", "Quinta", "Sexta", "Sábado"] + , abbr_day_names: ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"] + , month_names: [null, "Janeiro", "Fevereiro", "Março", "Abril", "Maio", "Junho", "Julho", "Agosto", "Setembro", "Outubro", "Novembro", "Dezembro"] + , abbr_month_names: [null, "Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"] + } + + , time: { + formats: { + "default": "%A, %d de %B de %Y, %H:%M h" + , "short": "%d/%m, %H:%M h" + , "long": "%A, %d de %B de %Y, %H:%M h" + , "short_with_placeholders": "%d/%m, %H:%M h {{p1}}" + } + , am: "AM" + , pm: "PM" + } + }; + + Translations["de"] = { + hello: "Hallo Welt!" + , date: { + day_names: ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"] + } + + , time: { + am: "de:AM" + , pm: "de:PM" + } + }; + + Translations["nb"] = { + hello: "Hei Verden!" + }; + + Translations["zh-Hant"] = { + cat: "貓" + , dragon: "龍" + }; + + Translations["zh"] = { + dog: "狗" + , dragon: "龙" + }; + + return Translations; + }; + + if (typeof define === 'function' && define.amd) { + define(function() { return generator; }); + } else if (typeof(exports) === "undefined") { + window.Translations = generator; + } else { + module.exports = generator; + } +})(); diff --git a/spec/js/utility_functions.spec.js b/spec/js/utility_functions.spec.js new file mode 100644 index 00000000..b6be26dc --- /dev/null +++ b/spec/js/utility_functions.spec.js @@ -0,0 +1,20 @@ +var I18n = require("../../app/assets/javascripts/i18n"); + +describe("Utility Functions", function(){ + beforeEach(function(){ + I18n.reset(); + }); + + describe("I18n.lookup", function() { + it("does not change locale on failed lookup", function(){ + var fallback_locales = ['fallback1', 'fallback2']; + + I18n.locales['lang'] = fallback_locales.slice(); + expect(I18n.locales.lang).toEqual(fallback_locales); + + I18n.lookup('anything', {locale: 'lang'}) + expect(I18n.locales.lang).toEqual(fallback_locales); + }); + + }) +}); diff --git a/spec/resources/custom_path.yml b/spec/resources/custom_path.yml deleted file mode 100644 index dcf81a0d..00000000 --- a/spec/resources/custom_path.yml +++ /dev/null @@ -1,4 +0,0 @@ -# Find more details about this configuration file at http://github.com/fnando/i18n-js -translations: - - file: "public/javascripts/translations/all.js" - only: "*" diff --git a/spec/resources/default.yml b/spec/resources/default.yml deleted file mode 100644 index 3e298f5b..00000000 --- a/spec/resources/default.yml +++ /dev/null @@ -1,4 +0,0 @@ -# Find more details about this configuration file at http://github.com/fnando/i18n-js -translations: - - file: "public/javascripts/translations.js" - only: "*" diff --git a/spec/resources/js_file_per_locale.yml b/spec/resources/js_file_per_locale.yml deleted file mode 100644 index 7348b0dc..00000000 --- a/spec/resources/js_file_per_locale.yml +++ /dev/null @@ -1,3 +0,0 @@ -translations: - - file: "public/javascripts/i18n/%{locale}.js" - only: '*' diff --git a/spec/resources/multiple_conditions.yml b/spec/resources/multiple_conditions.yml deleted file mode 100644 index 0de258c9..00000000 --- a/spec/resources/multiple_conditions.yml +++ /dev/null @@ -1,6 +0,0 @@ -# Find more details about this configuration file at http://github.com/fnando/i18n-js -translations: - - file: "public/javascripts/bitsnpieces.js" - only: - - "*.date.formats" - - "*.number.currency" diff --git a/spec/resources/multiple_files.yml b/spec/resources/multiple_files.yml deleted file mode 100644 index 121bcc0c..00000000 --- a/spec/resources/multiple_files.yml +++ /dev/null @@ -1,6 +0,0 @@ -# Find more details about this configuration file at http://github.com/fnando/i18n-js -translations: - - file: "public/javascripts/all.js" - only: "*" - - file: "public/javascripts/tudo.js" - only: "*" \ No newline at end of file diff --git a/spec/resources/no_scope.yml b/spec/resources/no_scope.yml deleted file mode 100644 index c3a15225..00000000 --- a/spec/resources/no_scope.yml +++ /dev/null @@ -1,3 +0,0 @@ -# Find more details about this configuration file at http://github.com/fnando/i18n-js -translations: - - file: "public/javascripts/no_scope.js" diff --git a/spec/resources/simple_scope.yml b/spec/resources/simple_scope.yml deleted file mode 100644 index 081a30c1..00000000 --- a/spec/resources/simple_scope.yml +++ /dev/null @@ -1,4 +0,0 @@ -# Find more details about this configuration file at http://github.com/fnando/i18n-js -translations: - - file: "public/javascripts/simple_scope.js" - only: "*.date.formats" diff --git a/spec/ruby/i18n/js/fallback_locales_spec.rb b/spec/ruby/i18n/js/fallback_locales_spec.rb new file mode 100644 index 00000000..612f79ca --- /dev/null +++ b/spec/ruby/i18n/js/fallback_locales_spec.rb @@ -0,0 +1,84 @@ +require "spec_helper" + +describe I18n::JS::FallbackLocales do + let(:locale) { :fr } + let(:default_locale) { :en } + + describe "#locales" do + let(:fallbacks_locales) { described_class.new(fallbacks, locale) } + subject { fallbacks_locales.locales } + + let(:fetching_locales) { proc do fallbacks_locales.locales end } + + context "when given true as fallbacks" do + let(:fallbacks) { true } + it { should eq([default_locale]) } + end + + context "when given false as fallbacks" do + let(:fallbacks) { false } + it { expect(fetching_locales).to raise_error(ArgumentError) } + end + + context "when given a valid locale as fallbacks" do + let(:fallbacks) { :de } + it { should eq([:de]) } + end + + context "when given a valid Array as fallbacks" do + let(:fallbacks) { [:de, :en] } + it { should eq([:de, :en]) } + end + + context "when given a valid Hash with current locale as key as fallbacks" do + let(:fallbacks) do { :fr => [:de, :en] } end + it { should eq([:de, :en]) } + end + + context "when given a valid Hash without current locale as key as fallbacks" do + let(:fallbacks) do { :de => [:fr, :en] } end + it { should eq([default_locale]) } + end + + context "when given a invalid locale as fallbacks" do + let(:fallbacks) { :invalid_locale } + it { should eq([:invalid_locale]) } + end + + context "when given a invalid type as fallbacks" do + let(:fallbacks) { 42 } + it { expect(fetching_locales).to raise_error(ArgumentError) } + end + + # I18n::Backend::Fallbacks + context "when I18n::Backend::Fallbacks is used" do + let(:backend_with_fallbacks) { backend_class_with_fallbacks.new } + + before do + I18n::JS.backend = backend_with_fallbacks + I18n.fallbacks[:fr] = [:de, :en] + end + after { I18n::JS.backend = I18n::Backend::Simple.new } + + context "given true as fallbacks" do + let(:fallbacks) { true } + it { should eq([:de, :en]) } + end + + context "given :default_locale as fallbacks" do + let(:fallbacks) { :default_locale } + it { should eq([:en]) } + end + + context "given a Hash with current locale as fallbacks" do + let(:fallbacks) do { :fr => [:en] } end + it { should eq([:en]) } + end + + context "given a Hash without current locale as fallbacks" do + let(:fallbacks) do { :de => [:en] } end + it { should eq([:de, :en]) } + end + end + end +end diff --git a/spec/ruby/i18n/js/segment_spec.rb b/spec/ruby/i18n/js/segment_spec.rb new file mode 100644 index 00000000..9ed47c64 --- /dev/null +++ b/spec/ruby/i18n/js/segment_spec.rb @@ -0,0 +1,219 @@ +require "spec_helper" + +describe I18n::JS::Segment do + + let(:file) { "tmp/i18n-js/segment.js" } + let(:translations){ { en: { "test" => "Test" }, fr: { "test" => "Test2" } } } + let(:namespace) { "MyNamespace" } + let(:pretty_print){ nil } + let(:json_only) { nil } + let(:js_extend) { nil } + let(:sort_translation_keys){ nil } + let(:options) { { namespace: namespace, + pretty_print: pretty_print, + json_only: json_only, + js_extend: js_extend, + sort_translation_keys: sort_translation_keys }.delete_if{|k,v| v.nil?} } + subject { I18n::JS::Segment.new(file, translations, options) } + + describe ".new" do + + it "should persist the file path variable" do + expect(subject.file).to eql("tmp/i18n-js/segment.js") + end + + it "should persist the translations variable" do + expect(subject.translations).to eql(translations) + end + + it "should persist the namespace variable" do + expect(subject.namespace).to eql("MyNamespace") + end + + context "when namespace is nil" do + let(:namespace){ nil } + + it "should default namespace to `I18n`" do + expect(subject.namespace).to eql("I18n") + end + end + + context "when namespace is not set" do + subject { I18n::JS::Segment.new(file, translations) } + + it "should default namespace to `I18n`" do + expect(subject.namespace).to eql("I18n") + end + end + + context "when pretty_print is nil" do + it "should set pretty_print to false" do + expect(subject.pretty_print).to be false + end + end + + context "when pretty_print is truthy" do + let(:pretty_print){ 1 } + + it "should set pretty_print to true" do + expect(subject.pretty_print).to be true + end + end + end + + describe "#saving!" do + before { allow(I18n::JS).to receive(:export_i18n_js_dir_path).and_return(temp_path) } + + context "when json_only is true with locale" do + let(:file){ "tmp/i18n-js/%{locale}.js" } + let(:json_only){ true } + + it 'should output JSON files per locale' do + subject.save! + file_should_exist "en.js" + file_should_exist "fr.js" + + expect(File.read(File.join(temp_path, "en.js"))).to eql( + %Q({"en":{"test":"Test"}}) + ) + + expect(File.read(File.join(temp_path, "fr.js"))).to eql( + %Q({"fr":{"test":"Test2"}}) + ) + end + end + + context "when json_only is true without locale" do + let(:file){ "tmp/i18n-js/segment.js" } + let(:json_only){ true } + + it 'should output one JSON file for all locales' do + subject.save! + file_should_exist "segment.js" + + expect(File.read(File.join(temp_path, "segment.js"))).to eql( + %Q({"en":{"test":"Test"},"fr":{"test":"Test2"}}) + ) + end + end + + context "when json_only and pretty print are true" do + let(:file){ "tmp/i18n-js/segment.js" } + let(:json_only){ true } + let(:pretty_print){ true } + + it 'should output one JSON file for all locales' do + subject.save! + file_should_exist "segment.js" + + expect(File.read(File.join(temp_path, "segment.js"))).to eql <<-EOS +{ + "en": { + "test": "Test" + }, + "fr": { + "test": "Test2" + } +} +EOS +.chomp + end + end + end + + describe "#save!" do + before { allow(I18n::JS).to receive(:export_i18n_js_dir_path).and_return(temp_path) } + before { subject.save! } + + context "when file does not include %{locale}" do + it "should write the file" do + file_should_exist "segment.js" + + expect(File.open(File.join(temp_path, "segment.js")){|f| f.read}).to eql <<-EOF +MyNamespace.translations || (MyNamespace.translations = {}); +MyNamespace.translations["en"] = I18n.extend((MyNamespace.translations["en"] || {}), {"test":"Test"}); +MyNamespace.translations["fr"] = I18n.extend((MyNamespace.translations["fr"] || {}), {"test":"Test2"}); + EOF + end + end + + context "when file includes %{locale}" do + let(:file){ "tmp/i18n-js/%{locale}.js" } + + it "should write files" do + file_should_exist "en.js" + file_should_exist "fr.js" + + expect(File.open(File.join(temp_path, "en.js")){|f| f.read}).to eql <<-EOF +MyNamespace.translations || (MyNamespace.translations = {}); +MyNamespace.translations["en"] = I18n.extend((MyNamespace.translations["en"] || {}), {"test":"Test"}); + EOF + + expect(File.open(File.join(temp_path, "fr.js")){|f| f.read}).to eql <<-EOF +MyNamespace.translations || (MyNamespace.translations = {}); +MyNamespace.translations["fr"] = I18n.extend((MyNamespace.translations["fr"] || {}), {"test":"Test2"}); + EOF + end + end + + context "when js_extend is true" do + let(:js_extend){ true } + + let(:translations){ { en: { "b" => "Test", "a" => "Test" } } } + + it 'should output the keys as sorted' do + file_should_exist "segment.js" + + expect(File.open(File.join(temp_path, "segment.js")){|f| f.read}).to eql <<-EOF +MyNamespace.translations || (MyNamespace.translations = {}); +MyNamespace.translations["en"] = I18n.extend((MyNamespace.translations["en"] || {}), {"a":"Test","b":"Test"}); + EOF + end + end + + context "when js_extend is false" do + let(:js_extend){ false } + + let(:translations){ { en: { "b" => "Test", "a" => "Test" } } } + + it 'should output the keys as sorted' do + file_should_exist "segment.js" + + expect(File.open(File.join(temp_path, "segment.js")){|f| f.read}).to eql <<-EOF +MyNamespace.translations || (MyNamespace.translations = {}); +MyNamespace.translations["en"] = {"a":"Test","b":"Test"}; + EOF + end + end + + context "when sort_translation_keys is true" do + let(:sort_translation_keys){ true } + + let(:translations){ { en: { "b" => "Test", "a" => "Test" } } } + + it 'should output the keys as sorted' do + file_should_exist "segment.js" + + expect(File.open(File.join(temp_path, "segment.js")){|f| f.read}).to eql <<-EOF +MyNamespace.translations || (MyNamespace.translations = {}); +MyNamespace.translations["en"] = I18n.extend((MyNamespace.translations["en"] || {}), {"a":"Test","b":"Test"}); + EOF + end + end + + context "when sort_translation_keys is false" do + let(:sort_translation_keys){ false } + + let(:translations){ { en: { "b" => "Test", "a" => "Test" } } } + + it 'should output the keys as sorted' do + file_should_exist "segment.js" + + expect(File.open(File.join(temp_path, "segment.js")){|f| f.read}).to eql <<-EOF +MyNamespace.translations || (MyNamespace.translations = {}); +MyNamespace.translations["en"] = I18n.extend((MyNamespace.translations["en"] || {}), {"b":"Test","a":"Test"}); + EOF + end + end + end +end diff --git a/spec/ruby/i18n/js/utils_spec.rb b/spec/ruby/i18n/js/utils_spec.rb new file mode 100644 index 00000000..6e3c87ed --- /dev/null +++ b/spec/ruby/i18n/js/utils_spec.rb @@ -0,0 +1,106 @@ +require "spec_helper" + +describe I18n::JS::Utils do + + describe ".strip_keys_with_nil_values" do + subject { described_class.strip_keys_with_nil_values(input_hash) } + + context 'when input_hash does NOT contain nil value' do + let(:input_hash) { {a: 1, b: { c: 2 }} } + let(:expected_hash) { input_hash } + + it 'returns the original input' do + is_expected.to eq expected_hash + end + end + context 'when input_hash does contain nil value' do + let(:input_hash) { {a: 1, b: { c: 2, d: nil }, e: { f: nil }} } + let(:expected_hash) { {a: 1, b: { c: 2 }, e: {}} } + + it 'returns the original input with nil values removed' do + is_expected.to eq expected_hash + end + end + end + + context "hash merging" do + it "performs a deep merge" do + target = {:a => {:b => 1}} + result = described_class.deep_merge(target, {:a => {:c => 2}}) + + expect(result[:a]).to eql({:b => 1, :c => 2}) + end + + it "performs a banged deep merge" do + target = {:a => {:b => 1}} + described_class.deep_merge!(target, {:a => {:c => 2}}) + + expect(target[:a]).to eql({:b => 1, :c => 2}) + end + end + + describe ".deep_reject" do + it "performs a deep keys rejection" do + hash = {:a => {:b => 1}} + + result = described_class.deep_reject(hash) { |k, v| k == :b } + + expect(result).to eql({:a => {}}) + end + + it "performs a deep keys rejection prunning the whole tree if necessary" do + hash = {:a => {:b => {:c => {:d => 1, :e => 2}}}} + + result = described_class.deep_reject(hash) { |k, v| k == :b } + + expect(result).to eql({:a => {}}) + end + + + it "performs a deep keys rejection without changing the original hash" do + hash = {:a => {:b => 1, :c => 2}} + + result = described_class.deep_reject(hash) { |k, v| k == :b } + + expect(result).to eql({:a => {:c => 2}}) + expect(hash).to eql({:a => {:b => 1, :c => 2}}) + end + end + + describe ".deep_key_sort" do + let(:unsorted_hash) { {:z => {:b => 1, :a => 2}, :y => 3} } + subject(:sorting) { described_class.deep_key_sort(unsorted_hash) } + + it "performs a deep keys sort without changing the original hash" do + should eql({:y => 3, :z => {:a => 2, :b => 1}}) + expect(unsorted_hash).to eql({:z => {:b => 1, :a => 2}, :y => 3}) + end + + # Idea from gem `rails_admin` + context "when hash contain non-Symbol as key" do + let(:unsorted_hash) { {:z => {1 => 1, true => 2}, :y => 3} } + + it "performs a deep keys sort without error" do + expect{ sorting }.to_not raise_error + end + it "converts keys to symbols" do + should eql({:y => 3, :z => {1 => 1, true => 2}}) + end + end + end + + describe ".scopes_match?" do + it "performs a comparison of literal scopes" do + expect(described_class.scopes_match?([:a, :b], [:a, :b, :c])).to_not eql true + expect(described_class.scopes_match?([:a, :b, :c], [:a, :b, :c])).to eql true + expect(described_class.scopes_match?([:a, :b, :c], [:a, :b, :d])).to_not eql true + end + + it "performs a comparison of wildcard scopes" do + expect(described_class.scopes_match?([:a, '*'], [:a, :b, :c])).to_not eql true + expect(described_class.scopes_match?([:a, '*', :c], [:a, :b, :c])).to eql true + expect(described_class.scopes_match?([:a, :b, :c], [:a, '*', :c])).to eql true + expect(described_class.scopes_match?([:a, :b, :c], [:a, '*', '*'])).to eql true + end + end +end diff --git a/spec/ruby/i18n/js_spec.rb b/spec/ruby/i18n/js_spec.rb new file mode 100644 index 00000000..136f21fe --- /dev/null +++ b/spec/ruby/i18n/js_spec.rb @@ -0,0 +1,748 @@ +require "spec_helper" + +describe I18n::JS do + + describe '.config_file_path' do + let(:default_path) { I18n::JS::DEFAULT_CONFIG_PATH } + let(:new_path) { File.join("tmp", default_path) } + + subject { described_class.config_file_path } + + context "when it is not set" do + it { should eq default_path } + end + context "when it is set already" do + before { described_class.config_file_path = new_path } + + it { should eq new_path } + end + end + + context "exporting" do + before do + stub_const('I18n::JS::DEFAULT_EXPORT_DIR_PATH', temp_path) + end + + it "exports messages to default path when configuration file doesn't exist" do + I18n::JS.export + file_should_exist "translations.js" + end + + it "exports messages using custom output path" do + set_config "custom_path.yml" + allow(I18n::JS::Segment).to receive(:new).with("tmp/i18n-js/all.js", translations, {js_extend: true, sort_translation_keys: true, json_only: false}).and_call_original + allow_any_instance_of(I18n::JS::Segment).to receive(:save!).with(no_args) + I18n::JS.export + end + + it "sets default scope to * when not specified" do + set_config "no_scope.yml" + allow(I18n::JS::Segment).to receive(:new).with("tmp/i18n-js/no_scope.js", translations, {js_extend: true, sort_translation_keys: true, json_only: false}).and_call_original + allow_any_instance_of(I18n::JS::Segment).to receive(:save!).with(no_args) + I18n::JS.export + end + + it "exports to multiple files" do + set_config "multiple_files.yml" + I18n::JS.export + + file_should_exist "all.js" + file_should_exist "tudo.js" + end + + it "ignores an empty config file" do + set_config "no_config.yml" + I18n::JS.export + + file_should_exist "translations.js" + end + + it "exports to a JS file per available locale" do + set_config "js_file_per_locale.yml" + I18n::JS.export + + file_should_exist "en.js" + file_should_exist "fr.js" + + en_output = File.read(File.join(I18n::JS.export_i18n_js_dir_path, "en.js")) + expect(en_output).to eq(< true, + :config_file_path => config_file_path, + ) + end + + # Shortcut to I18n::JS.translations + def translations + I18n::JS.translations + end + + def file_should_exist(name) + file_path = File.join(temp_path, name) + expect(File.file?(file_path)).to eq(true) + end + + def temp_path(file_name = "") + File.expand_path("../../tmp/i18n-js/#{file_name}", __FILE__) + end + + + def self.included(base) + base.let(:backend_class_with_fallbacks) do + klass = Class.new(I18n::Backend::Simple) + klass.send(:include, I18n::Backend::Fallbacks) + klass + end + end +end + +RSpec.configure do |config| + config.before do + I18n.load_path = [File.dirname(__FILE__) + "/fixtures/locales.yml"] + FileUtils.rm_rf(temp_path) + end + + config.after do + FileUtils.rm_rf(temp_path) + end + + config.include Helpers -# Stub Rails.root, so we don"t need to load the whole Rails environment. -# Be careful! The specified folder will be removed! -Rails = OpenStruct.new({ - :root => Pathname.new(File.dirname(__FILE__) + "/tmp"), - :version => "0" -}) + # Remove deprecation warnings + config.expect_with :rspec do |c| + c.syntax = [:expect] + end + config.mock_with :rspec do |c| + c.syntax = [:expect] + end +end -require "i18n-js" diff --git a/vendor/assets/javascripts/i18n.js b/vendor/assets/javascripts/i18n.js deleted file mode 100644 index 944c55d3..00000000 --- a/vendor/assets/javascripts/i18n.js +++ /dev/null @@ -1,531 +0,0 @@ -// https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/indexOf -if (!Array.prototype.indexOf) { - Array.prototype.indexOf = function(searchElement /*, fromIndex */) { - "use strict"; - - if (this === void 0 || this === null) { - throw new TypeError(); - } - - var t = Object(this); - var len = t.length >>> 0; - - if (len === 0) { - return -1; - } - - var n = 0; - if (arguments.length > 0) { - n = Number(arguments[1]); - if (n !== n) { // shortcut for verifying if it's NaN - n = 0; - } else if (n !== 0 && n !== (Infinity) && n !== -(Infinity)) { - n = (n > 0 || -1) * Math.floor(Math.abs(n)); - } - } - - if (n >= len) { - return -1; - } - - var k = n >= 0 - ? n - : Math.max(len - Math.abs(n), 0); - - for (; k < len; k++) { - if (k in t && t[k] === searchElement) { - return k; - } - } - - return -1; - }; -} - -// Instantiate the object -var I18n = I18n || {}; - -// Set default locale to english -I18n.defaultLocale = "en"; - -// Set default handling of translation fallbacks to false -I18n.fallbacks = false; - -// Set default separator -I18n.defaultSeparator = "."; - -// Set current locale to null -I18n.locale = null; - -// Set the placeholder format. Accepts `{{placeholder}}` and `%{placeholder}`. -I18n.PLACEHOLDER = /(?:\{\{|%\{)(.*?)(?:\}\}?)/gm; - -I18n.fallbackRules = { -}; - -I18n.pluralizationRules = { - en: function (n) { - return n == 0 ? ["zero", "none", "other"] : n == 1 ? "one" : "other"; - } -}; - -I18n.getFallbacks = function(locale) { - if (locale === I18n.defaultLocale) { - return []; - } else if (!I18n.fallbackRules[locale]) { - var rules = [] - , components = locale.split("-"); - - for (var l = 1; l < components.length; l++) { - rules.push(components.slice(0, l).join("-")); - } - - rules.push(I18n.defaultLocale); - - I18n.fallbackRules[locale] = rules; - } - - return I18n.fallbackRules[locale]; -} - -I18n.isValidNode = function(obj, node, undefined) { - return obj[node] !== null && obj[node] !== undefined; -}; - -I18n.lookup = function(scope, options) { - var options = options || {} - , lookupInitialScope = scope - , translations = this.prepareOptions(I18n.translations) - , locale = options.locale || I18n.currentLocale() - , messages = translations[locale] || {} - , options = this.prepareOptions(options) - , currentScope - ; - - if (typeof(scope) == "object") { - scope = scope.join(this.defaultSeparator); - } - - if (options.scope) { - scope = options.scope.toString() + this.defaultSeparator + scope; - } - - scope = scope.split(this.defaultSeparator); - - while (messages && scope.length > 0) { - currentScope = scope.shift(); - messages = messages[currentScope]; - } - - if (!messages) { - if (I18n.fallbacks) { - var fallbacks = this.getFallbacks(locale); - for (var fallback = 0; fallback < fallbacks.length; fallbacks++) { - messages = I18n.lookup(lookupInitialScope, this.prepareOptions({locale: fallbacks[fallback]}, options)); - if (messages) { - break; - } - } - } - - if (!messages && this.isValidNode(options, "defaultValue")) { - messages = options.defaultValue; - } - } - - return messages; -}; - -// Merge serveral hash options, checking if value is set before -// overwriting any value. The precedence is from left to right. -// -// I18n.prepareOptions({name: "John Doe"}, {name: "Mary Doe", role: "user"}); -// #=> {name: "John Doe", role: "user"} -// -I18n.prepareOptions = function() { - var options = {} - , opts - , count = arguments.length - ; - - for (var i = 0; i < count; i++) { - opts = arguments[i]; - - if (!opts) { - continue; - } - - for (var key in opts) { - if (!this.isValidNode(options, key)) { - options[key] = opts[key]; - } - } - } - - return options; -}; - -I18n.interpolate = function(message, options) { - options = this.prepareOptions(options); - var matches = message.match(this.PLACEHOLDER) - , placeholder - , value - , name - ; - - if (!matches) { - return message; - } - - for (var i = 0; placeholder = matches[i]; i++) { - name = placeholder.replace(this.PLACEHOLDER, "$1"); - - value = options[name]; - - if (!this.isValidNode(options, name)) { - value = "[missing " + placeholder + " value]"; - } - - regex = new RegExp(placeholder.replace(/\{/gm, "\\{").replace(/\}/gm, "\\}")); - message = message.replace(regex, value); - } - - return message; -}; - -I18n.translate = function(scope, options) { - options = this.prepareOptions(options); - var translation = this.lookup(scope, options); - - try { - if (typeof(translation) == "object") { - if (typeof(options.count) == "number") { - return this.pluralize(options.count, scope, options); - } else { - return translation; - } - } else { - return this.interpolate(translation, options); - } - } catch(err) { - return this.missingTranslation(scope); - } -}; - -I18n.localize = function(scope, value) { - switch (scope) { - case "currency": - return this.toCurrency(value); - case "number": - scope = this.lookup("number.format"); - return this.toNumber(value, scope); - case "percentage": - return this.toPercentage(value); - default: - if (scope.match(/^(date|time)/)) { - return this.toTime(scope, value); - } else { - return value.toString(); - } - } -}; - -I18n.parseDate = function(date) { - var matches, convertedDate; - - // we have a date, so just return it. - if (typeof(date) == "object") { - return date; - }; - - // it matches the following formats: - // yyyy-mm-dd - // yyyy-mm-dd[ T]hh:mm::ss - // yyyy-mm-dd[ T]hh:mm::ss - // yyyy-mm-dd[ T]hh:mm::ssZ - // yyyy-mm-dd[ T]hh:mm::ss+0000 - // - matches = date.toString().match(/(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2}))?(Z|\+0000)?/); - - if (matches) { - for (var i = 1; i <= 6; i++) { - matches[i] = parseInt(matches[i], 10) || 0; - } - - // month starts on 0 - matches[2] -= 1; - - if (matches[7]) { - convertedDate = new Date(Date.UTC(matches[1], matches[2], matches[3], matches[4], matches[5], matches[6])); - } else { - convertedDate = new Date(matches[1], matches[2], matches[3], matches[4], matches[5], matches[6]); - } - } else if (typeof(date) == "number") { - // UNIX timestamp - convertedDate = new Date(); - convertedDate.setTime(date); - } else if (date.match(/\d+ \d+:\d+:\d+ [+-]\d+ \d+/)) { - // a valid javascript format with timezone info - convertedDate = new Date(); - convertedDate.setTime(Date.parse(date)) - } else { - // an arbitrary javascript string - convertedDate = new Date(); - convertedDate.setTime(Date.parse(date)); - } - - return convertedDate; -}; - -I18n.toTime = function(scope, d) { - var date = this.parseDate(d) - , format = this.lookup(scope) - ; - - if (date.toString().match(/invalid/i)) { - return date.toString(); - } - - if (!format) { - return date.toString(); - } - - return this.strftime(date, format); -}; - -I18n.strftime = function(date, format) { - var options = this.lookup("date"); - - if (!options) { - return date.toString(); - } - - options.meridian = options.meridian || ["AM", "PM"]; - - var weekDay = date.getDay() - , day = date.getDate() - , year = date.getFullYear() - , month = date.getMonth() + 1 - , hour = date.getHours() - , hour12 = hour - , meridian = hour > 11 ? 1 : 0 - , secs = date.getSeconds() - , mins = date.getMinutes() - , offset = date.getTimezoneOffset() - , absOffsetHours = Math.floor(Math.abs(offset / 60)) - , absOffsetMinutes = Math.abs(offset) - (absOffsetHours * 60) - , timezoneoffset = (offset > 0 ? "-" : "+") + (absOffsetHours.toString().length < 2 ? "0" + absOffsetHours : absOffsetHours) + (absOffsetMinutes.toString().length < 2 ? "0" + absOffsetMinutes : absOffsetMinutes) - ; - - if (hour12 > 12) { - hour12 = hour12 - 12; - } else if (hour12 === 0) { - hour12 = 12; - } - - var padding = function(n) { - var s = "0" + n.toString(); - return s.substr(s.length - 2); - }; - - var f = format; - f = f.replace("%a", options.abbr_day_names[weekDay]); - f = f.replace("%A", options.day_names[weekDay]); - f = f.replace("%b", options.abbr_month_names[month]); - f = f.replace("%B", options.month_names[month]); - f = f.replace("%d", padding(day)); - f = f.replace("%e", day); - f = f.replace("%-d", day); - f = f.replace("%H", padding(hour)); - f = f.replace("%-H", hour); - f = f.replace("%I", padding(hour12)); - f = f.replace("%-I", hour12); - f = f.replace("%m", padding(month)); - f = f.replace("%-m", month); - f = f.replace("%M", padding(mins)); - f = f.replace("%-M", mins); - f = f.replace("%p", options.meridian[meridian]); - f = f.replace("%S", padding(secs)); - f = f.replace("%-S", secs); - f = f.replace("%w", weekDay); - f = f.replace("%y", padding(year)); - f = f.replace("%-y", padding(year).replace(/^0+/, "")); - f = f.replace("%Y", year); - f = f.replace("%z", timezoneoffset); - - return f; -}; - -I18n.toNumber = function(number, options) { - options = this.prepareOptions( - options, - this.lookup("number.format"), - {precision: 3, separator: ".", delimiter: ",", strip_insignificant_zeros: false} - ); - - var negative = number < 0 - , string = Math.abs(number).toFixed(options.precision).toString() - , parts = string.split(".") - , precision - , buffer = [] - , formattedNumber - ; - - number = parts[0]; - precision = parts[1]; - - while (number.length > 0) { - buffer.unshift(number.substr(Math.max(0, number.length - 3), 3)); - number = number.substr(0, number.length -3); - } - - formattedNumber = buffer.join(options.delimiter); - - if (options.precision > 0) { - formattedNumber += options.separator + parts[1]; - } - - if (negative) { - formattedNumber = "-" + formattedNumber; - } - - if (options.strip_insignificant_zeros) { - var regex = { - separator: new RegExp(options.separator.replace(/\./, "\\.") + "$") - , zeros: /0+$/ - }; - - formattedNumber = formattedNumber - .replace(regex.zeros, "") - .replace(regex.separator, "") - ; - } - - return formattedNumber; -}; - -I18n.toCurrency = function(number, options) { - options = this.prepareOptions( - options, - this.lookup("number.currency.format"), - this.lookup("number.format"), - {unit: "$", precision: 2, format: "%u%n", delimiter: ",", separator: "."} - ); - - number = this.toNumber(number, options); - number = options.format - .replace("%u", options.unit) - .replace("%n", number) - ; - - return number; -}; - -I18n.toHumanSize = function(number, options) { - var kb = 1024 - , size = number - , iterations = 0 - , unit - , precision - ; - - while (size >= kb && iterations < 4) { - size = size / kb; - iterations += 1; - } - - if (iterations === 0) { - unit = this.t("number.human.storage_units.units.byte", {count: size}); - precision = 0; - } else { - unit = this.t("number.human.storage_units.units." + [null, "kb", "mb", "gb", "tb"][iterations]); - precision = (size - Math.floor(size) === 0) ? 0 : 1; - } - - options = this.prepareOptions( - options, - {precision: precision, format: "%n%u", delimiter: ""} - ); - - number = this.toNumber(size, options); - number = options.format - .replace("%u", unit) - .replace("%n", number) - ; - - return number; -}; - -I18n.toPercentage = function(number, options) { - options = this.prepareOptions( - options, - this.lookup("number.percentage.format"), - this.lookup("number.format"), - {precision: 3, separator: ".", delimiter: ""} - ); - - number = this.toNumber(number, options); - return number + "%"; -}; - -I18n.pluralizer = function(locale) { - pluralizer = this.pluralizationRules[locale]; - if (pluralizer !== undefined) return pluralizer; - return this.pluralizationRules["en"]; -}; - -I18n.findAndTranslateValidNode = function(keys, translation) { - for (i = 0; i < keys.length; i++) { - key = keys[i]; - if (this.isValidNode(translation, key)) return translation[key]; - } - return null; -}; - -I18n.pluralize = function(count, scope, options) { - var translation; - - try { - translation = this.lookup(scope, options); - } catch (error) {} - - if (!translation) { - return this.missingTranslation(scope); - } - - var message; - options = this.prepareOptions(options); - options.count = count.toString(); - - pluralizer = this.pluralizer(this.currentLocale()); - key = pluralizer(Math.abs(count)); - keys = ((typeof key == "object") && (key instanceof Array)) ? key : [key]; - - message = this.findAndTranslateValidNode(keys, translation); - if (message == null) message = this.missingTranslation(scope, keys[0]); - - return this.interpolate(message, options); -}; - -I18n.missingTranslation = function() { - var message = '[missing "' + this.currentLocale() - , count = arguments.length - ; - - for (var i = 0; i < count; i++) { - message += "." + arguments[i]; - } - - message += '" translation]'; - - return message; -}; - -I18n.currentLocale = function() { - return (I18n.locale || I18n.defaultLocale); -}; - -// shortcuts -I18n.t = I18n.translate; -I18n.l = I18n.localize; -I18n.p = I18n.pluralize; diff --git a/vendor/assets/javascripts/i18n/translations.js.erb b/vendor/assets/javascripts/i18n/translations.js.erb deleted file mode 100644 index bb7c51ff..00000000 --- a/vendor/assets/javascripts/i18n/translations.js.erb +++ /dev/null @@ -1,9 +0,0 @@ -<%# encoding: utf-8%> - -<% SimplesIdeias::I18n.assert_usable_configuration! %> -var I18n = I18n || {}; -I18n.translations = <%= - SimplesIdeias::I18n.translation_segments.each_with_object({}) do |(name, segment),translations| - translations.merge!(segment) - end.to_json -%>; diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 00000000..8283260c --- /dev/null +++ b/yarn.lock @@ -0,0 +1,131 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +coffeescript@>=1.0.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/coffeescript/-/coffeescript-2.3.1.tgz#a25f69c251d25805c9842e57fc94bfc453ef6aed" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + +gaze@~1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/gaze/-/gaze-1.1.3.tgz#c441733e13b927ac8c0ff0b4c3b033f28812924a" + dependencies: + globule "^1.0.0" + +glob@~7.1.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globule@^1.0.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/globule/-/globule-1.2.1.tgz#5dffb1b191f22d20797a9369b49eab4e9839696d" + dependencies: + glob "~7.1.1" + lodash "~4.17.10" + minimatch "~3.0.2" + +growl@^1.10.2: + version "1.10.5" + resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + +jasmine-growl-reporter@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/jasmine-growl-reporter/-/jasmine-growl-reporter-1.0.1.tgz#375306cef1fbf6357ad7913ca0358aa2285d6d39" + dependencies: + growl "^1.10.2" + +jasmine-node@^1.14.5: + version "1.15.0" + resolved "https://registry.yarnpkg.com/jasmine-node/-/jasmine-node-1.15.0.tgz#d5e9a92623c111f55e4b83ff2ab0407ddbc2a5b7" + dependencies: + coffeescript ">=1.0.1" + gaze "~1.1.2" + jasmine-growl-reporter "~1.0.1" + jasmine-reporters "~1.0.0" + mkdirp "~0.3.5" + requirejs ">=0.27.1" + underscore ">= 1.3.1" + walkdir ">= 0.0.1" + +jasmine-reporters@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/jasmine-reporters/-/jasmine-reporters-1.0.2.tgz#ab613ed5977dc7487e85b3c12f6a8ea8db2ade31" + dependencies: + mkdirp "~0.3.5" + +lodash@~4.17.10: + version "4.17.20" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + +minimatch@^3.0.4, minimatch@~3.0.2: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + dependencies: + brace-expansion "^1.1.7" + +mkdirp@~0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.5.tgz#de3e5f8961c88c787ee1368df849ac4413eca8d7" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + dependencies: + wrappy "1" + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + +requirejs@>=0.27.1: + version "2.3.5" + resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.5.tgz#617b9acbbcb336540ef4914d790323a8d4b861b0" + +"underscore@>= 1.3.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.1.tgz#06dce34a0e68a7babc29b365b8e74b8925203961" + +"walkdir@>= 0.0.1": + version "0.0.12" + resolved "https://registry.yarnpkg.com/walkdir/-/walkdir-0.0.12.tgz#2f24f1ade64aab1e458591d4442c8868356e9281" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"