Skip to content
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

Use GasketData with Intl packages #223

Merged
merged 12 commits into from
Dec 10, 2020
27,095 changes: 20,195 additions & 6,900 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/gasket-data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ npm i @gasket/data
## Usage

This helper is intended for use in conjunction with Gasket Data embedded in a
script tag in the html document.
[script tag] in the HTML document.

For example, if the following data is rendered...

Expand Down Expand Up @@ -58,4 +58,4 @@ described above.
<!-- LINKS -->

[middleware lifecycle]:/packages/gasket-plugin-express/README.md#middleware

[script tag]:https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script
87 changes: 82 additions & 5 deletions packages/gasket-helper-intl/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
* Do not rely on req, or window.
*/

const path = require('path');
const merge = require('lodash.merge');

/**
* Partial URL representing a directory containing locale .json files
* or a URL template with a `:locale` path param to a .json file.
Expand Down Expand Up @@ -49,6 +52,34 @@
* "en-US"
*/

/**
* State of loaded locale files
*
* @typedef {object} LocalesState
* @property {{string: string}} messages
* @property {{LocalePath: LocalePathStatus}} status
*/

/**
* Props for a Next.js page containing locale and initial state
*
* @typedef {LocalesState} LocalesProps
* @property {Locale} locale
*/

/**
* Fetch status of a locale file
* @typedef {string} LocalePathStatus
* @readonly
*/

/** @type {LocalePathStatus} */
const LOADING = 'loading';
/** @type {LocalePathStatus} */
const LOADED = 'loaded';
/** @type {LocalePathStatus} */
const ERROR = 'error';

const reLocalePathParam = /(\/[$:{]locale}?\/)/;

/**
Expand Down Expand Up @@ -88,18 +119,20 @@ function LocaleUtils(config) {
};

/**
* Format a localePath with provide locale
* Format a localePath with provided locale. Ensures path starts with slash
* and ends with .json file.
*
* @param {LocalePathPart} localePathPart - Path containing locale files
* @param {Locale} locale - Locale
* @returns {LocalePath} localePath
* @method
*/
this.formatLocalePath = (localePathPart, locale) => {
if (reLocalePathParam.test(localePathPart)) {
return localePathPart.replace(reLocalePathParam, `/${ locale }/`);
const cleanPart = '/' + localePathPart.replace(/^\/|\/$/g, '');
if (reLocalePathParam.test(cleanPart)) {
return cleanPart.replace(reLocalePathParam, `/${ locale }/`);
}
return `${ localePathPart }/${ locale }.json`;
return `${ cleanPart }/${ locale }.json`;
};

/**
Expand Down Expand Up @@ -135,8 +168,52 @@ function LocaleUtils(config) {
if (hash) url += `?v=${ hash }`;
return url;
};

/**
* Load locale file(s) and return localesProps
*
* @param {LocalePathPart|LocalePathPart[]} localePathPath - Path(s) containing locale files
* @param {Locale} locale - Locale to load
* @param {string} localesDir - Disk path to locale files dir
* @returns {LocalesProps} localesProps
*/
this.serverLoadData = (localePathPath, locale, localesDir) => {
if (Array.isArray(localePathPath)) {
const localesProps = localePathPath.map(p => this.serverLoadData(p, locale, localesDir));
return merge(...localesProps);
}

const localeFile = this.getLocalePath(localePathPath, locale);
const diskPath = path.join(localesDir, localeFile);
let messages;
let status;

try {
messages = require(diskPath);
status = LOADED;
} catch (e) {
console.error(e.message); // eslint-disable-line no-console
messages = {};
status = ERROR;
}

return {
locale,
messages: {
[locale]: {
...messages
}
},
status: {
[localeFile]: status
}
};
};
}

module.exports = {
LocaleUtils
LocaleUtils,
LOADING,
LOADED,
ERROR
};
7 changes: 6 additions & 1 deletion packages/gasket-helper-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
"url": "https://github.com/godaddy/gasket/issues"
},
"homepage": "https://github.com/godaddy/gasket/tree/master/packages/gasket-helper-intl",
"dependencies": {
"lodash.merge": "^4.6.0"
},
"devDependencies": {
"@godaddy/dmd": "^1.0.0",
"assume": "^2.2.0",
Expand All @@ -45,7 +48,9 @@
"jsdoc-to-markdown": "^5.0.1",
"mocha": "^6.2.0",
"nyc": "^14.1.1",
"setup-env": "^1.2.2"
"proxyquire": "^2.1.3",
"setup-env": "^1.2.2",
"sinon": "^7.4.1"
},
"eslintConfig": {
"extends": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"gasket_welcome": "Hello!",
"gasket_learn": "Learn Gasket"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"gasket_extra": "Extra"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"gasket_extra": "Supplémentaire"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"gasket_welcome": "Bonjour!",
"gasket_learn": "Apprendre Gasket"
}
65 changes: 65 additions & 0 deletions packages/gasket-helper-intl/test/index.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const assume = require('assume');
const sinon = require('sinon');
const path = require('path');

const mockManifest = require('./fixtures/mock-manifest.json');
const mockConfig = {};
Expand All @@ -9,27 +11,45 @@ describe('LocaleUtils', function () {
let utils;

beforeEach(function () {
sinon.stub(console, 'error');
mockConfig.manifest = { ...mockManifest, paths: { ...mockManifest.paths } };
utils = new LocaleUtils(mockConfig);
});

afterEach(function () {
sinon.restore();
});

describe('.formatLocalePath', function () {
it('adds locale json to root path', function () {
const results = utils.formatLocalePath('/locales', 'en-US');
assume(results).equals('/locales/en-US.json');
});

it('substitutes $locale in path template', function () {
const results = utils.formatLocalePath('/locales/$locale/page1.json', 'en-US');
assume(results).equals('/locales/en-US/page1.json');
});

it('substitutes :locale in path template', function () {
const results = utils.formatLocalePath('/locales/:locale/page1.json', 'en-US');
assume(results).equals('/locales/en-US/page1.json');
});

it('substitutes {locale} in path template', function () {
const results = utils.formatLocalePath('/locales/{locale}/page1.json', 'en-US');
assume(results).equals('/locales/en-US/page1.json');
});

it('ensures forward slash', function () {
const results = utils.formatLocalePath('locales', 'en-US');
assume(results).equals('/locales/en-US.json');
});

it('ensures no extra end slash', function () {
const results = utils.formatLocalePath('locales/', 'en-US');
assume(results).equals('/locales/en-US.json');
});
});

describe('.pathToUrl', function () {
Expand Down Expand Up @@ -101,4 +121,49 @@ describe('LocaleUtils', function () {
assume(results).equals('/locales/fake.json');
});
});

describe('.serverLoadData', function () {

const localesParentDir = path.resolve(__dirname, 'fixtures');

it('returns localesProps for other path part', async function () {
const results = utils.serverLoadData('/locales/extra', 'en-US', localesParentDir);
assume(results).eqls({
locale: 'en-US',
messages: { 'en-US': { gasket_extra: 'Extra' } },
status: { '/locales/extra/en-US.json': 'loaded' }
});
});

it('returns localesProps for multiple locale path parts', async function () {
const results = utils.serverLoadData(['/locales', '/locales/extra'], 'en-US', localesParentDir);
assume(results).eqls({
locale: 'en-US',
messages: { 'en-US': { gasket_welcome: 'Hello!', gasket_learn: 'Learn Gasket', gasket_extra: 'Extra' } },
status: {
'/locales/en-US.json': 'loaded',
'/locales/extra/en-US.json': 'loaded'
}
});
});

it('returns localesProps with error for missing path', async function () {
const results = utils.serverLoadData('/locales/missing', 'en-US', localesParentDir);
assume(results).eqls({
locale: 'en-US',
messages: { 'en-US': {} },
status: { '/locales/missing/en-US.json': 'error' }
});
assume(console.error).is.calledWithMatch('Cannot find module');
});

it('returns localesProps for default if locale missing', async function () {
const results = utils.serverLoadData('/locales', 'fr-CA', localesParentDir);
assume(results).eqls({
locale: 'fr-CA',
messages: { 'fr-CA': { gasket_welcome: 'Hello!', gasket_learn: 'Learn Gasket' } },
status: { '/locales/en-US.json': 'loaded' }
});
});
});
});
78 changes: 68 additions & 10 deletions packages/gasket-plugin-intl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ required. However, these options exist to customize an app's setup.
### Options

- `basePath` - (string) Base URL where locale files are served
- `localesPath` - (string) Path to endpoint with JSON files (default:
- `defaultPath` - (string) Path to endpoint with JSON files (default:
`/locales`). See [Locales Path] section.
- `defaultLocale` - (string) Locale to fallback to when loading files (default:
`en`)
Expand Down Expand Up @@ -71,10 +71,10 @@ module.exports = {

## Usage

Loader packages, such as [@gasket/react-intl] for React and Next.js apps, can utilize
settings from the [locales manifest] for loading locale files. Also, for apps
with a server element, request based settings can be made available with the
[response data].
Loader packages, such as [@gasket/react-intl] for React and Next.js apps, can
utilize settings from the [locales manifest] for loading locale files. Also, for
apps with a server element, request based settings can be made available with
the response via [Gasket data].

For the most part, app developers should not need to interface directly with
these setting objects, but rather understand how loaders use them to resolve
Expand Down Expand Up @@ -211,16 +211,72 @@ Because the locales manifest JSON file is generated each build, you may want to
configure your SCM to ignore committing this file, such as with a `.gitignore`
entry.

### Response Data
## Gasket Data

Request based settings are available from the response object at
`res.gasketData.intl`. For apps that support server-rendering, the
`res.gasketData` object can be rendered as a [global window object] to make the
`intl` settings further available to loader packages in the browser.
`res.locals.gasketData.intl`. For apps that support server-rendering, the
`res.locals.gasketData` object can be rendered as a [global window object] to
make the `intl` settings further available to loader packages in the browser.

For instance, this could be used to customize the `locale` for a user, by
implementing a custom Gasket plugin using the [intlLocale lifecycle].

### withLocaleRequired

**Signature**

- `req.withLocaleRequired(localesPath)`

This loader method is attached to the request object which allows locale paths
to be loaded on the server. The loaded locale props will added into Gasket data
at `res.locals.gasketData.intl`, which can be pre-rendered into a
[GasketData script tag] to avoid an extra request.

```js
// lifecycles/middleware.js

module.exports = function middlewareHook(gasket) {
return middleware(req, res, next) {
req.withLocaleRequired('/locales');
next();
}
}
```

For Next.js apps, prefer to use one of the loader approaches provided by
[@gasket/react-intl/next].

### selectLocaleMessages

**Signature**

- `req.selectLocaleMessages(id, [defaultMessage])`

If you have cases where you need locale messages loaded for non HTML documents,
such as for as translated API responses, as a convenience, you can use this
method to select a loaded message for the request locale.

```js
// lifecycles/express.js

module.exports = function expressHook(gasket, app) {
app.post('/api/v1/something', async function (req, res) {
// first, load messages for the request locale at the locale path
req.withLocaleRequired('/locales/api');

const ok = doSomething();

// send a translated response message based on results
if (ok) {
res.send(req.selectLocaleMessage('success'));
} else {
// Provide a default message incase a locale file as a missing id
res.status(500).send(req.selectLocaleMessage('exception', 'Bad things man'));
}
});
}
```

## Lifecycles

### intlLocale
Expand Down Expand Up @@ -295,10 +351,12 @@ entry.
[locales map]:#locales-map
[locales manifest]:#locales-manifest
[module locales]:#locales-manifest
[response data]:#response-data
[Gasket data]:#gasket-data
[intlLocale lifecycle]:#intllocale

[@gasket/react-intl]: /packages/gasket-react-intl/README.md
[@gasket/react-intl/next]: /packages/gasket-react-intl/README.md#nextjs
[GasketData script tag]: /packages/gasket-data/README.md

[global window object]:https://developer.mozilla.org/en-US/docs/Glossary/Global_object

Loading