-
Notifications
You must be signed in to change notification settings - Fork 81
Language Negotiation #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
fluent-langneg.js | ||
compat.js |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
docs | ||
test | ||
makefile |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
# Changelog | ||
|
||
## Unreleased | ||
|
||
- … | ||
|
||
## fluent-langneg 0.0.1 | ||
|
||
- Introduce Language Negotiation module for Fluent | ||
|
||
The initial release. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
# fluent-langneg | ||
|
||
`fluent-langneg` is an API for negotiating languages. It's part of | ||
Project Fluent, a localization framework designed to unleash | ||
the expressive power of the natural language. | ||
|
||
## Installation | ||
|
||
`fluent-langneg` can be used both on the client-side and the server-side. | ||
You can install it from the npm registry or use it as a standalone script. | ||
|
||
npm install fluent-langneg | ||
|
||
|
||
## How to use | ||
|
||
```javascript | ||
import negotiateLanguages from 'fluent-langneg'; | ||
|
||
const supportedLocales = negotiateLanguages( | ||
requestedLocales, availableLocales, defaultLocale | ||
); | ||
``` | ||
|
||
The API reference is available at | ||
http://projectfluent.io/fluent.js/fluent-langneg. | ||
|
||
## Strategies | ||
|
||
The API supports three negotiation strategies: | ||
|
||
### filtering (default) | ||
|
||
In this strategy the algorithm will look for the best matching available | ||
locale for each requested locale. | ||
|
||
Example: | ||
|
||
requested: ['de-DE', 'fr-FR'] | ||
available: ['it', 'de', 'en-US', 'fr-CA', 'de-DE', 'fr', 'de-AU'] | ||
|
||
supported: ['de-DE', 'fr'] | ||
|
||
### matching | ||
|
||
In this strategy the algorithm will try to match as many available locales | ||
as possible for each of the requested locale. | ||
|
||
Example: | ||
|
||
requested: ['de-DE', 'fr-FR'] | ||
available: ['it', 'de', 'en-US', 'fr-CA', 'de-DE', 'fr', 'de-AU'] | ||
|
||
supported: ['de-DE', 'de', 'fr', 'fr-CA'] | ||
|
||
### lookup | ||
|
||
In this strategy the algorithm will try to find the single best locale | ||
for the requested locale list among the available locales. | ||
|
||
Example: | ||
|
||
requested: ['de-DE', 'fr-FR'] | ||
available: ['it', 'de', 'en-US', 'fr-CA', 'de-DE', 'fr', 'de-AU'] | ||
|
||
supported: ['de-DE'] | ||
|
||
### API use: | ||
|
||
```javascript | ||
let supported = negotiateLanguages(requested, available, { | ||
strategy: 'matching', | ||
}); | ||
``` | ||
|
||
## Likely subtags | ||
|
||
Fluent Language Negotiation module carries its own minimal list of likely | ||
subtags data, which is useful in finding most likely available locales | ||
in case the requested locale is too generic. | ||
|
||
An example of that scenario is when the user requests `en` locale, and | ||
the application supportes `en-GB` and `en-US`. | ||
|
||
Unicode CLDR maintains a complete list of likely subtags that the | ||
user can load into `fluent-langneg` to replace the minimal version. | ||
|
||
```javascript | ||
let data = require('cldr-core/supplemental/likelySubtags.json'); | ||
|
||
let supported = negotiateLanguages(requested, available, { | ||
likelySubtags: data.supplemental.likelySubtags | ||
}); | ||
``` | ||
|
||
## Learn more | ||
|
||
Find out more about Project Fluent at [projectfluent.io][], including | ||
documentation of the Fluent file format ([FTL][]), links to other packages and | ||
implementations, and information about how to get involved. | ||
|
||
|
||
[projectfluent.io]: http://projectfluent.io | ||
[FTL]: http://projectfluent.io/fluent/guide/ |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
PACKAGE := fluent-langneg | ||
GLOBAL := FluentLangNeg | ||
|
||
include ../common.mk | ||
|
||
build: $(PACKAGE).js | ||
compat: compat.js | ||
|
||
$(PACKAGE).js: $(SOURCES) | ||
@rollup $(CURDIR)/src/index.js \ | ||
--format umd \ | ||
--id $(PACKAGE) \ | ||
--name $(GLOBAL) \ | ||
--output $@ | ||
@echo -e " $(OK) $@ built" | ||
|
||
compat.js: $(PACKAGE).js | ||
@babel --presets latest $< > $@ | ||
@echo -e " $(OK) $@ built" | ||
|
||
clean: | ||
@rm -f $(PACKAGE).js compat.js | ||
@echo -e " $(OK) clean" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
{ | ||
"name": "fluent-langneg", | ||
"description": "Language Negotiation API for Fluent", | ||
"version": "0.0.1", | ||
"homepage": "http://projectfluent.io", | ||
"author": "Mozilla <l10n-drivers@mozilla.org>", | ||
"license": "Apache-2.0", | ||
"contributors": [ | ||
{ | ||
"name": "Zibi Braniecki", | ||
"email": "zbraniecki@mozilla.com" | ||
}, | ||
{ | ||
"name": "Staś Małolepszy", | ||
"email": "stas@mozilla.com" | ||
} | ||
], | ||
"directories": { | ||
"lib": "./src" | ||
}, | ||
"main": "./fluent-langneg.js", | ||
"repository": { | ||
"type": "git", | ||
"url": "https://github.com/projectfluent/fluent.js.git" | ||
}, | ||
"keywords": [ | ||
"localization", | ||
"l10n" | ||
], | ||
"engine": { | ||
"node": ">=6" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
/* | ||
* @module fluent-langneg | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add the |
||
* @overview | ||
* | ||
* `fluent-langneg` provides language negotiation API that fits into | ||
* Project Fluent localization composition and fallbacking strategy. | ||
* | ||
*/ | ||
|
||
import filterMatches from './matches'; | ||
|
||
function GetOption(options, property, type, values, fallback) { | ||
let value = options[property]; | ||
|
||
if (value !== undefined) { | ||
if (type === 'boolean') { | ||
value = new Boolean(value); | ||
} else if (type === 'string') { | ||
value = value.toString(); | ||
} | ||
|
||
if (values !== undefined && values.indexOf(value) === -1) { | ||
throw new Error('Invalid option value'); | ||
} | ||
|
||
return value; | ||
} | ||
|
||
return fallback; | ||
} | ||
|
||
/** | ||
* Negotiates the languages between the list of requested locales against | ||
* a list of available locales. | ||
* | ||
* It accepts three arguments: | ||
* | ||
* requestedLocales: | ||
* an Array of strings with BCP47 locale IDs sorted | ||
* according to user preferences. | ||
* | ||
* availableLocales: | ||
* an Array of strings with BCP47 locale IDs of locale for which | ||
* resources are available. Unsorted. | ||
* | ||
* options: | ||
* An object with the following, optional keys: | ||
* | ||
* strategy: 'filtering' (default) | 'matching' | 'lookup' | ||
* | ||
* defaultLocale: | ||
* a string with BCP47 locale ID to be used | ||
* as a last resort locale. | ||
* | ||
* likelySubtags: | ||
* a key-value map of locale keys to their most expanded variants. | ||
* For example: | ||
* 'en' -> 'en-Latn-US', | ||
* 'ru' -> 'ru-Cyrl-RU', | ||
* | ||
* | ||
* It returns an Array of strings with BCP47 locale IDs sorted according to the | ||
* user preferences. | ||
* | ||
* The exact list will be selected differently depending on the strategy: | ||
* | ||
* 'filtering': | ||
* In the filtering strategy, the algorithm will attempt to find the | ||
* best possible match for each element of the requestedLocales list. | ||
* | ||
* 'matching': | ||
* In the matching strategy, the algorithm will attempt to match | ||
* as many keys in the available locales in order of the requested locales. | ||
* | ||
* 'lookup': | ||
* In the lookup strategy, the algorithm will attempt to find a single | ||
* best available locale based on the requested locales list. | ||
*/ | ||
export default function negotiateLanguages( | ||
requestedLocales, | ||
availableLocales, | ||
options = {} | ||
) { | ||
|
||
const defaultLocale = GetOption(options, 'defaultLocale', 'string'); | ||
const likelySubtags = GetOption( | ||
options, 'likelySubtags', 'object', undefined); | ||
const strategy = GetOption(options, 'strategy', 'string', | ||
['filtering', 'matching', 'lookup'], 'filtering'); | ||
|
||
if (strategy === 'lookup' && defaultLocale === undefined) { | ||
throw new Error('defaultLocale cannot be undefined for strategy `lookup`'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This condition should be in the docs. |
||
} | ||
|
||
const supportedLocales = filterMatches( | ||
requestedLocales, availableLocales, strategy, likelySubtags | ||
); | ||
|
||
if (strategy === 'lookup') { | ||
if (supportedLocales.length === 0) { | ||
supportedLocales.push(defaultLocale); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it's undefined it'll throw earlier. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, I didn't notice that. Thanks. |
||
} | ||
return supportedLocales; | ||
} | ||
|
||
if (defaultLocale && !supportedLocales.includes(defaultLocale)) { | ||
supportedLocales.push(defaultLocale); | ||
} | ||
return supportedLocales; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
/* eslint no-magic-numbers: 0 */ | ||
|
||
const languageCodeRe = '([a-z]{2,3}|\\*)'; | ||
const scriptCodeRe = '(?:-([a-z]{4}|\\*))'; | ||
const regionCodeRe = '(?:-([a-z]{2}|\\*))'; | ||
const variantCodeRe = '(?:-([a-z]+|\\*))'; | ||
|
||
/** | ||
* Regular expression splitting locale id into four pieces: | ||
* | ||
* Example: `en-Latn-US-mac` | ||
* | ||
* language: en | ||
* script: Latn | ||
* region: US | ||
* variant: mac | ||
* | ||
* It can also accept a range `*` character on any position. | ||
*/ | ||
const localeRe = new RegExp( | ||
`^${languageCodeRe}${scriptCodeRe}?${regionCodeRe}?${variantCodeRe}?$`, 'i'); | ||
|
||
export const localeParts = ['language', 'script', 'region', 'variant']; | ||
|
||
export default class Locale { | ||
/** | ||
* Parses a locale id using the localeRe into an array with four elements. | ||
* | ||
* If the second argument `range` is set to true, it places range `*` char | ||
* in place of any missing piece. | ||
* | ||
* It also allows skipping the script section of the id, so `en-US` is | ||
* properly parsed as `en-*-US-*`. | ||
*/ | ||
constructor(locale, range = false) { | ||
const result = localeRe.exec(locale); | ||
if (!result) { | ||
return; | ||
} | ||
|
||
const missing = range ? '*' : undefined; | ||
|
||
const language = result[1] || missing; | ||
const script = result[2] || missing; | ||
const region = result[3] || missing; | ||
const variant = result[4] || missing; | ||
|
||
this.language = language; | ||
this.script = script; | ||
this.region = region; | ||
this.variant = variant; | ||
} | ||
|
||
isEqual(locale) { | ||
return localeParts.every(part => this[part] === locale[part]); | ||
} | ||
|
||
matches(locale) { | ||
return localeParts.every(part => { | ||
return this[part] === '*' || locale[part] === '*' || | ||
(this[part] === undefined && locale[part] === undefined) || | ||
(this[part] !== undefined && locale[part] !== undefined && | ||
this[part].toLowerCase() === locale[part].toLowerCase()); | ||
}); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't the general use-case within fluent be to use
matching
instead offiltering
? In that case, I'd make that default.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's up to @stasm I guess.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Implemented it in C++ as the default, I'll update js version once I'm done with C++.