Skip to content

Commit f7df9ed

Browse files
committed
feature: fetch json source
1 parent 17899c8 commit f7df9ed

File tree

4 files changed

+261
-24
lines changed

4 files changed

+261
-24
lines changed

README.md

Lines changed: 105 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,17 @@ Messages are defined in a datastructure which maps _message keys_ to _message
3131
content_. In its simplest form, a messages structure looks like the following:
3232

3333
```javascript
34-
{
35-
greeting: "Hello, world"
36-
}
34+
const messages = new Map()
35+
.put("greeting", "Hello, world")
36+
.put("bye", "See you soon")
37+
3738
```
3839

39-
This defines a single message identified by the key `greeting` and the content `"Hello, world"`.
40+
This defines a single message identified by the key `greeting` and the content `Hello, world`
41+
as well as a message with key `bye` and the content `See you soon`. Note that in this
42+
example all keys and messages are `string`s. While this is true for all message keys,
43+
messages may also be a nested `Map` when used with _plurals_ (see below).
44+
4045

4146
## Loading Messages
4247

@@ -82,9 +87,8 @@ first argument.
8287
If you have a messages definition like
8388

8489
```javascript
85-
{
86-
"greeting": "Welcome, {{0}}!",
87-
}
90+
const messages = new Map()
91+
.put("greeting", "Welcome, {{0}}!")
8892
```
8993

9094
and you perform a call like
@@ -108,12 +112,12 @@ You can define a message's content to be an object with keys describing the _amo
108112
Here is the message definition from the example above:
109113

110114
```javascript
111-
{
112-
"inbox.summary": {
113-
0: "No new message",
114-
1: "One new message",
115-
n: "{{n}} new messages",
116-
},
115+
const messages = new Map()
116+
.put("inbox.summary", new Map()
117+
.put(0, "No new message")
118+
.put(1, "One new message")
119+
.put(n, "{{n}} new messages")
120+
)
117121
}
118122
```
119123

@@ -127,14 +131,12 @@ console.log(messageResolver.mpl("inbox.summary", numberOfNewMessages))
127131
You can use placeholders in plural messages and provide additional arguments, such as:
128132

129133
```javascript
130-
{
131-
"inbox.folder.summary": {
132-
0: "Your {{0}} folder contains no messages.",
133-
1: "Your {{0}} folder contains one message.",
134-
n: "Your {{0}} folder contains {{n}} messages.",
135-
}
136-
}
137-
134+
const messages = new Map()
135+
.put("inbox.folder.summary", new Map()
136+
.put(0, "Your {{0}} folder contains no messages.")
137+
.put(1, "Your {{0}} folder contains one message.")
138+
.put(n, "Your {{0}} folder contains {{n}} messages.")
139+
)
138140
// ...
139141

140142
console.log(messageResolver.mpl("inbox.folder.summary", numberOfMessages, "trash"))
@@ -150,11 +152,25 @@ Make sure you also check out the source code and doc comments for the classes.
150152
A `MessageLoader` that "loads" messages from a given object. This message loader is typically be
151153
used in an environment where messages are `import`ed from static files during Javascript assembly.
152154

155+
The object loader's input uses plain Javascript `object`s like the following:
156+
157+
```javascript
158+
{
159+
"message.key": "message content",
160+
"plural.message.key": {
161+
"0": "empty message",
162+
"n": "{{n}} message",
163+
},
164+
}
165+
```
166+
167+
See the following example for how to use the `ObjectMessageLoader`:
168+
153169
```javascript
154170
import { MessageResolver, ObjectMessageLoader } from "@weccoframework/i18n"
155-
import { en, de, fr } from "./messages.json"
171+
import { en, de, fr } from "./messages"
156172

157-
const messageResolver = MessageResolver.create(new ObjectMessageLoader(en, {
173+
const messageResolver = await MessageResolver.create(new ObjectMessageLoader(en, {
158174
en: en,
159175
de: de,
160176
fr: fr,
@@ -164,9 +180,74 @@ const messageResolver = MessageResolver.create(new ObjectMessageLoader(en, {
164180
This pattern is very easy to get running and works very for few translations with a small number
165181
of messages.
166182

183+
### JsonMessageLoader
184+
185+
The `JsonMessageLoader` supports loading messages from `JsonSource`. A `JsonSource` is a function
186+
which accepts a language as a single argument and retuns a `Promise` resolving to a JSON-formatted
187+
`string` which defines the messages. The format of the JSON messages is almost identical to the one used
188+
by the `ObjectMessageLoader`:
189+
190+
```json
191+
{
192+
"message.key": "message content",
193+
"plural.message.key": {
194+
"0": "empty message",
195+
"n": "{{n}} message",
196+
},
197+
}
198+
```
199+
200+
To construct a `JsonMessagesLoader` you need to pass at least one `JsonSource`. You may pass
201+
two. In this case the first one is used to load the default messages while the second one is
202+
used to load the localized messages.
203+
204+
```javascript
205+
const jsonSource = (language) => {
206+
// ...
207+
// return a Promise resolving to a string.
208+
// When loading the default messages, language is undefined
209+
}
210+
211+
const messageResolver = await MessageResolver.create(new JsonMessageLoader(jsonSource))
212+
```
213+
214+
#### fetchJsonSource
215+
216+
`i18n` comes with with a ready to use implementation of `JsonSource`
217+
which uses `fetch` to load messages identified by language. The implementation uses
218+
the following conventions to load messages:
219+
220+
* all messages are located under a given _base URL_
221+
* default messages can be loaded from `<baseURL>/default.json`
222+
* localized messages for `<lang>` can be loaded from `<baseURL>/<lang>.json`
223+
224+
You can pass additional request init options, such as `CORS` mode, change the
225+
request method (which defaults to `GET`), set headers, ...
226+
167227
### CascadingMessageLoader
228+
The `CascadingMessageLoader` loads messages by using a set of underlying `MessageLoader`s
229+
and merging the messages returned by each loader together in a way similiar to how CSS
230+
rules are merged (thus the name). Use this loader to get an aggregated set of messages
231+
from multiple sources.
232+
233+
The following (rather academic) example demonstrates the merging.
234+
235+
```javascript
236+
237+
const loader1 = new ObjectMessageLoader({
238+
foo: "foo",
239+
bar: "bar",
240+
})
241+
242+
const loader2 = new ObjectMessageLoader({
243+
bar: "Bar",
244+
spam: "Eggs",
245+
})
168246

169-
**TODO**
247+
const messageResolver = await MessageResolver.create(new CascadingMessageLoader(loader1, loader2))
248+
249+
console.log(messageResolver.m("bar")) // Logs: Bar
250+
```
170251

171252
# Author
172253

index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ export { Language, Messages } from "./src/Messages"
2020
export { MessageLoader } from "./src/MessageLoader"
2121
export { ObjectMessageLoader, CascadingMessageLoader, JsonMessageLoader, JsonSource } from "./src/loaders"
2222
export { MessageResolver, MessageResolvingError } from "./src/MessageResolver"
23+
export { fetchJsonSource} from "./src/jsonsource"

src/jsonsource.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* This file is part of wecco.
3+
*
4+
* Copyright (c) 2021 Alexander Metzner.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
import { JsonSource } from "./loaders"
20+
import { Language } from "./Messages"
21+
22+
/**
23+
* Creates new `JsonSource` which loads messages by making a HTTP `GET` request.
24+
* The URL is constructed from the given `baseUrl` and the language. For every
25+
* language, the URL is `<baseUrl>/<language>.json`. When language is undefined,
26+
* the default messages is loaded from `<baseUrl>/default.json`.
27+
*
28+
* @param baseUrl the baseUrl
29+
* @param options additional options being passed to every `fetch` call
30+
* @returns a `JsonSource`
31+
*/
32+
export function fetchJsonSource(baseUrl: string, options?: RequestInit): JsonSource {
33+
return (language?: Language): Promise<string | undefined> => {
34+
const url = `${baseUrl}/${language ?? "default"}.json`
35+
return fetch(url, {
36+
body: options?.body,
37+
cache: options?.cache,
38+
credentials: options?.credentials,
39+
headers: options?.headers,
40+
integrity: options?.integrity,
41+
keepalive: options?.keepalive,
42+
method: options?.method ?? "GET",
43+
mode: options?.mode,
44+
redirect: options?.redirect,
45+
referrer: options?.referrer,
46+
referrerPolicy: options?.referrerPolicy,
47+
signal: options?.signal,
48+
window: options?.window,
49+
})
50+
.then(response => {
51+
if (response.status < 300) {
52+
return response.text()
53+
}
54+
throw `Got unexpected status code when loading '${url}': ${response.status} (${response.statusText})`
55+
})
56+
}
57+
}

test/jsonsource.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* This file is part of wecco.
3+
*
4+
* Copyright (c) 2021 Alexander Metzner.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
import { expect } from "iko"
20+
import { fetchJsonSource } from ".."
21+
import { JsonSource } from "../src/loaders"
22+
23+
class ResponseMock implements Response {
24+
headers: Headers
25+
ok: boolean
26+
redirected: boolean
27+
status: number
28+
statusText: string
29+
trailer: Promise<Headers>
30+
type: ResponseType
31+
url: string
32+
body: ReadableStream<Uint8Array> | null
33+
bodyUsed: boolean
34+
35+
private readonly bodyText: string
36+
37+
constructor (status: number, body: string) {
38+
this.status = status
39+
this.bodyText = body
40+
}
41+
42+
async arrayBuffer(): Promise<ArrayBuffer> {
43+
throw `Not implemented`
44+
}
45+
46+
async blob(): Promise<Blob> {
47+
throw `Not implemented`
48+
}
49+
50+
async formData(): Promise<FormData> {
51+
throw `Not implemented`
52+
}
53+
54+
async json(): Promise<any> {
55+
return JSON.parse(this.bodyText)
56+
}
57+
58+
async text(): Promise<string> {
59+
return this.bodyText
60+
}
61+
62+
clone(): Response {
63+
return this
64+
}
65+
}
66+
67+
describe("fetchJsonSource", () => {
68+
let source: JsonSource
69+
let url: string
70+
let requestInit: RequestInit
71+
72+
before(() => {
73+
global.fetch = function (u: string, i: RequestInit): Promise<Response> {
74+
url = u
75+
requestInit = i
76+
77+
return Promise.resolve(new ResponseMock(200, `{"foo": "bar"}`))
78+
}
79+
80+
source = fetchJsonSource("/test/path", {
81+
method: "POST",
82+
})
83+
})
84+
85+
it("should load default.json", async () => {
86+
const json = await source()
87+
expect(json).toBe(`{"foo": "bar"}`)
88+
expect(url).toBe("/test/path/default.json")
89+
expect(requestInit.method).toBe("POST")
90+
})
91+
92+
it("should load de.json", async () => {
93+
const json = await source("de")
94+
expect(json).toBe(`{"foo": "bar"}`)
95+
expect(url).toBe("/test/path/de.json")
96+
expect(requestInit.method).toBe("POST")
97+
})
98+
})

0 commit comments

Comments
 (0)