Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions fluent-langneg/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
fluent-langneg.js
compat.js
3 changes: 3 additions & 0 deletions fluent-langneg/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
docs
test
makefile
11 changes: 11 additions & 0 deletions fluent-langneg/CHANGELOG.md
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.
104 changes: 104 additions & 0 deletions fluent-langneg/README.md
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)
Copy link
Contributor

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 of filtering? In that case, I'd make that default.

Copy link
Collaborator Author

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.

Copy link
Collaborator Author

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++.


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/
23 changes: 23 additions & 0 deletions fluent-langneg/makefile
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"
33 changes: 33 additions & 0 deletions fluent-langneg/package.json
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"
}
}
110 changes: 110 additions & 0 deletions fluent-langneg/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* @module fluent-langneg
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add the @overview tag here.

* @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`');
Copy link
Contributor

Choose a reason for hiding this comment

The 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the defaultLocale is undefined? Are you okay pushing it to supportedLocales too?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's undefined it'll throw earlier.

Copy link
Contributor

Choose a reason for hiding this comment

The 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;
}
66 changes: 66 additions & 0 deletions fluent-langneg/src/locale.js
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());
});
}
}
Loading