@client-compose/client 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -3
- package/dist/index.d.ts +69 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +175 -14
- package/dist/index.js.map +1 -1
- package/dist/proxy.d.ts +3 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +20 -0
- package/dist/proxy.js.map +1 -1
- package/dist/strings.d.ts +26 -0
- package/dist/strings.d.ts.map +1 -0
- package/dist/strings.js +130 -0
- package/dist/strings.js.map +1 -0
- package/dist/testing.d.ts +52 -7
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +121 -10
- package/dist/testing.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +250 -22
- package/src/proxy.ts +28 -0
- package/src/strings.ts +178 -0
- package/src/testing.ts +178 -12
package/src/index.ts
CHANGED
|
@@ -12,31 +12,90 @@ import {
|
|
|
12
12
|
type IslandEntry,
|
|
13
13
|
type IslandRequest,
|
|
14
14
|
type IslandResponse,
|
|
15
|
+
type PluralRules,
|
|
15
16
|
PROTOCOL_VERSION,
|
|
17
|
+
type ScreenHead,
|
|
16
18
|
type ScreenRequest,
|
|
17
19
|
type ScreenResponse,
|
|
20
|
+
type StringParams,
|
|
18
21
|
} from "@client-compose/protocol";
|
|
19
22
|
|
|
23
|
+
import { buildStrings } from "./strings.ts";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
appStrings,
|
|
27
|
+
buildStrings,
|
|
28
|
+
fetchStrings,
|
|
29
|
+
formatString,
|
|
30
|
+
pluralCategory,
|
|
31
|
+
StringsFetchError,
|
|
32
|
+
type StringValues,
|
|
33
|
+
} from "./strings.ts";
|
|
34
|
+
|
|
20
35
|
type OkEntry<P = unknown> = IslandEntry<P> & { status: "ok" };
|
|
21
36
|
type ErrorEntry = IslandEntry & { status: "error" };
|
|
22
37
|
|
|
23
|
-
|
|
38
|
+
/**
|
|
39
|
+
* An island's props as its renderer gets them: with its strings as `t` (spec §4.7), if its
|
|
40
|
+
* descriptor declares any.
|
|
41
|
+
*/
|
|
42
|
+
export type WithStrings<P, S> = P & (unknown extends S ? unknown : { t: S });
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Renders an island: its props (with `t`, if it declares strings), its entry as the composer sent
|
|
46
|
+
* it, and the screen's plural rules (spec §4.7).
|
|
47
|
+
*/
|
|
48
|
+
export type IslandRenderer<R, P = unknown> = (props: P, entry: OkEntry, plurals?: PluralRules) => R;
|
|
49
|
+
|
|
50
|
+
/** What a client shows for an island still pending in a stream's head (spec §4.6). */
|
|
51
|
+
export type IslandPlaceholder<R> = () => R;
|
|
24
52
|
|
|
25
53
|
export class IslandRegistry<R> {
|
|
26
54
|
private renderers = new Map<string, Map<number, IslandRenderer<R>>>();
|
|
55
|
+
private placeholders = new Map<string, IslandPlaceholder<R>>();
|
|
56
|
+
private declaredStrings = new Map<string, StringParams>();
|
|
57
|
+
// Each entry's props with its `t`, made once: a screen streaming in plans again with each island
|
|
58
|
+
// that arrives, and the islands already shown keep the props they were rendered with.
|
|
59
|
+
private propsMade = new WeakMap<object, { plurals?: PluralRules; props: unknown }>();
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Register a placeholder for an island version, shown while it's pending in a stream's head
|
|
63
|
+
* (spec §4.6). Each app draws its own: the protocol says nothing about how one looks.
|
|
64
|
+
*/
|
|
65
|
+
registerPlaceholder(island: IslandDescriptor<unknown>, placeholder: IslandPlaceholder<R>): this {
|
|
66
|
+
this.placeholders.set(`${island.type}@${island.version}`, placeholder);
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
27
69
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
70
|
+
/** The placeholder for an island version, if one was registered. */
|
|
71
|
+
placeholder(type: string, version: number): IslandPlaceholder<R> | undefined {
|
|
72
|
+
return this.placeholders.get(`${type}@${version}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Register a renderer for a generated island descriptor: `register(ProfileHeaderV2, (props) => …)`.
|
|
77
|
+
* If the island declares strings, its props have them as `t`: `props.t.heading`.
|
|
78
|
+
*/
|
|
79
|
+
register<P, S>(
|
|
80
|
+
island: IslandDescriptor<P, S>,
|
|
81
|
+
render: IslandRenderer<R, WithStrings<P, S>>,
|
|
82
|
+
): this;
|
|
30
83
|
register<P>(type: string, version: number, render: IslandRenderer<R, P>): this;
|
|
31
|
-
register<P>(
|
|
32
|
-
islandOrType: IslandDescriptor<P> | string,
|
|
33
|
-
versionOrRender: number | IslandRenderer<R, P
|
|
84
|
+
register<P, S>(
|
|
85
|
+
islandOrType: IslandDescriptor<P, S> | string,
|
|
86
|
+
versionOrRender: number | IslandRenderer<R, WithStrings<P, S>>,
|
|
34
87
|
maybeRender?: IslandRenderer<R, P>,
|
|
35
88
|
): this {
|
|
36
89
|
if (typeof islandOrType !== "string") {
|
|
37
90
|
if (typeof versionOrRender === "number") {
|
|
38
91
|
throw new Error("register(descriptor, render): pass the renderer, not a version");
|
|
39
92
|
}
|
|
93
|
+
const id = `${islandOrType.type}@${islandOrType.version}`;
|
|
94
|
+
if (islandOrType.strings) {
|
|
95
|
+
this.declaredStrings.set(id, islandOrType.strings);
|
|
96
|
+
} else {
|
|
97
|
+
this.declaredStrings.delete(id);
|
|
98
|
+
}
|
|
40
99
|
return this.add(islandOrType.type, islandOrType.version, versionOrRender);
|
|
41
100
|
}
|
|
42
101
|
if (typeof versionOrRender !== "number" || !maybeRender) {
|
|
@@ -67,6 +126,27 @@ export class IslandRegistry<R> {
|
|
|
67
126
|
get(type: string, version: number): IslandRenderer<R> | undefined {
|
|
68
127
|
return this.renderers.get(type)?.get(version);
|
|
69
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* An island's props as its renderer gets them: with its strings as `t`, built with the screen's
|
|
132
|
+
* plural rules, if its descriptor declares any.
|
|
133
|
+
*/
|
|
134
|
+
propsOf(entry: OkEntry, plurals: PluralRules | undefined): unknown {
|
|
135
|
+
const declared = this.declaredStrings.get(`${entry.type}@${entry.version}`);
|
|
136
|
+
if (!declared) {
|
|
137
|
+
return entry.props;
|
|
138
|
+
}
|
|
139
|
+
const made = this.propsMade.get(entry);
|
|
140
|
+
if (made && made.plurals === plurals) {
|
|
141
|
+
return made.props;
|
|
142
|
+
}
|
|
143
|
+
const props = {
|
|
144
|
+
...(typeof entry.props === "object" ? entry.props : {}),
|
|
145
|
+
t: buildStrings(declared, entry.strings, plurals),
|
|
146
|
+
};
|
|
147
|
+
this.propsMade.set(entry, { plurals, props });
|
|
148
|
+
return props;
|
|
149
|
+
}
|
|
70
150
|
}
|
|
71
151
|
|
|
72
152
|
export function buildRequest(
|
|
@@ -87,6 +167,8 @@ export function buildRequest(
|
|
|
87
167
|
export interface PlannedIsland<R> {
|
|
88
168
|
key: string;
|
|
89
169
|
render: () => R;
|
|
170
|
+
/** It's a placeholder, for an island still pending in a stream's head (spec §4.6). */
|
|
171
|
+
pending?: boolean;
|
|
90
172
|
}
|
|
91
173
|
|
|
92
174
|
export type SkipReason = "unknown_island" | "unknown_status";
|
|
@@ -98,14 +180,30 @@ export interface PlanOptions<R> {
|
|
|
98
180
|
onSkipped?: (entry: IslandEntry, reason: SkipReason) => void;
|
|
99
181
|
}
|
|
100
182
|
|
|
101
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* Turn a screen, or a stream's head (spec §4.6), into an ordered list of renderable islands. An
|
|
185
|
+
* island still pending is its registered placeholder, or left out until it arrives.
|
|
186
|
+
*/
|
|
102
187
|
export function plan<R>(
|
|
103
188
|
registry: IslandRegistry<R>,
|
|
104
|
-
response: ScreenResponse,
|
|
189
|
+
response: ScreenResponse | ScreenHead,
|
|
105
190
|
options: PlanOptions<R> = {},
|
|
106
191
|
): PlannedIsland<R>[] {
|
|
107
192
|
const out: PlannedIsland<R>[] = [];
|
|
108
193
|
for (const entry of response.islands) {
|
|
194
|
+
if (entry.status === "pending") {
|
|
195
|
+
const placeholder = registry.get(entry.type, entry.version)
|
|
196
|
+
? registry.placeholder(entry.type, entry.version)
|
|
197
|
+
: undefined;
|
|
198
|
+
if (placeholder) {
|
|
199
|
+
out.push({
|
|
200
|
+
key: entry.key,
|
|
201
|
+
render: placeholder,
|
|
202
|
+
pending: true,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
109
207
|
if (entry.status === "error") {
|
|
110
208
|
const fallback = options.onError?.(entry);
|
|
111
209
|
if (fallback !== undefined) {
|
|
@@ -122,7 +220,8 @@ export function plan<R>(
|
|
|
122
220
|
options.onSkipped?.(entry, "unknown_island");
|
|
123
221
|
continue;
|
|
124
222
|
}
|
|
125
|
-
|
|
223
|
+
const props = registry.propsOf(entry, response.plurals);
|
|
224
|
+
out.push({ key: entry.key, render: () => renderer(props, entry, response.plurals) });
|
|
126
225
|
}
|
|
127
226
|
return out;
|
|
128
227
|
}
|
|
@@ -150,13 +249,6 @@ function isIslandEntry(value: unknown): value is IslandEntry {
|
|
|
150
249
|
return named && typeof value.version === "number" && typeof value.status === "string";
|
|
151
250
|
}
|
|
152
251
|
|
|
153
|
-
function isScreenResponse(value: unknown): value is ScreenResponse {
|
|
154
|
-
if (!isRecord(value) || typeof value.screen !== "string") {
|
|
155
|
-
return false;
|
|
156
|
-
}
|
|
157
|
-
return Array.isArray(value.islands) && value.islands.every(isIslandEntry);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
252
|
/** An action's outcome: what a client needs of one is its status (the client fixtures, spec §8.2). */
|
|
161
253
|
function isActionResponse(value: unknown): value is ActionResponse {
|
|
162
254
|
return isRecord(value) && (value.status === "ok" || value.status === "error");
|
|
@@ -199,27 +291,163 @@ function jsonHeaders(init: HeadersInit | undefined): Headers {
|
|
|
199
291
|
return headers;
|
|
200
292
|
}
|
|
201
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Fetch a screen, all of it: its stream (spec §4.6) read to the end. Throws ScreenFetchError for a
|
|
296
|
+
* non-2xx response (e.g. `not_found`) or one that isn't a screen. To show its islands as they
|
|
297
|
+
* arrive, use openScreen.
|
|
298
|
+
*/
|
|
202
299
|
export async function fetchScreen(
|
|
203
300
|
baseUrl: string,
|
|
204
301
|
request: ScreenRequest,
|
|
205
302
|
init: RequestInit = {},
|
|
206
303
|
fetchImpl: typeof fetch = fetch,
|
|
207
304
|
): Promise<ScreenResponse> {
|
|
305
|
+
return collectScreen(await openScreen(baseUrl, request, init, fetchImpl));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---- Streams (spec §4.6) ----
|
|
309
|
+
|
|
310
|
+
/** A screen as it arrives: its head, then each pending island as the composer resolves it. */
|
|
311
|
+
export interface ScreenStream {
|
|
312
|
+
/** The screen, its islands resolved or pending. */
|
|
313
|
+
head: ScreenHead;
|
|
314
|
+
/**
|
|
315
|
+
* Each pending island's entry, as it arrives. If the stream ends early, those that never came
|
|
316
|
+
* arrive as errors: `incomplete`, retryable.
|
|
317
|
+
*/
|
|
318
|
+
islands: AsyncIterable<IslandEntry>;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Request a screen, and resolve once its head has arrived. Throws ScreenFetchError for a non-2xx
|
|
323
|
+
* response (e.g. `not_found`) or one that isn't a screen's stream.
|
|
324
|
+
*/
|
|
325
|
+
export async function openScreen(
|
|
326
|
+
baseUrl: string,
|
|
327
|
+
request: ScreenRequest,
|
|
328
|
+
init: RequestInit = {},
|
|
329
|
+
fetchImpl: typeof fetch = fetch,
|
|
330
|
+
): Promise<ScreenStream> {
|
|
208
331
|
const res = await fetchImpl(`${baseUrl}/screens/${encodeURIComponent(request.screen)}`, {
|
|
209
332
|
...init,
|
|
210
333
|
method: "POST",
|
|
211
334
|
headers: jsonHeaders(init.headers),
|
|
212
335
|
body: JSON.stringify(request),
|
|
213
336
|
});
|
|
214
|
-
const text = await res.text();
|
|
215
337
|
if (!res.ok) {
|
|
216
|
-
throw new ScreenFetchError(res.status, text);
|
|
338
|
+
throw new ScreenFetchError(res.status, await res.text());
|
|
217
339
|
}
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
340
|
+
const lines = linesOf(res)[Symbol.asyncIterator]();
|
|
341
|
+
const first = await lines.next();
|
|
342
|
+
const head = first.done ? undefined : parseJson(first.value);
|
|
343
|
+
if (!isRecord(head) || head.kind !== "head" || !isScreenHead(head)) {
|
|
344
|
+
await lines.return?.();
|
|
345
|
+
throw new ScreenFetchError(res.status, first.done ? "" : first.value);
|
|
346
|
+
}
|
|
347
|
+
const { kind: _kind, ...screen } = head;
|
|
348
|
+
return { head: screen, islands: pendingIslands(screen, lines) };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Reads the rest of a stream, and returns the screen with every island in place. */
|
|
352
|
+
export async function collectScreen(stream: ScreenStream): Promise<ScreenResponse> {
|
|
353
|
+
let head = stream.head;
|
|
354
|
+
for await (const entry of stream.islands) {
|
|
355
|
+
head = withIsland(head, entry);
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
...head,
|
|
359
|
+
islands: head.islands.filter((e): e is IslandEntry => e.status !== "pending"),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The head with a pending island's entry in its place; unchanged if the key isn't pending. */
|
|
364
|
+
export function withIsland(head: ScreenHead, entry: IslandEntry): ScreenHead {
|
|
365
|
+
return {
|
|
366
|
+
...head,
|
|
367
|
+
islands: head.islands.map((e) => (e.key === entry.key && e.status === "pending" ? entry : e)),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** A screen's head and end as a stream, for a screen that's already complete (spec §4.6). */
|
|
372
|
+
export function screenLines(screen: ScreenResponse): string {
|
|
373
|
+
return `${JSON.stringify({ kind: "head", ...screen })}\n${JSON.stringify({ kind: "end" })}\n`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isScreenHead(
|
|
377
|
+
value: Record<string, unknown>,
|
|
378
|
+
): value is Record<string, unknown> & ScreenHead {
|
|
379
|
+
return (
|
|
380
|
+
typeof value.screen === "string" &&
|
|
381
|
+
Array.isArray(value.islands) &&
|
|
382
|
+
value.islands.every(isIslandEntry)
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The islands pending in `head`, as `lines` bring them, until the end line. */
|
|
387
|
+
async function* pendingIslands(
|
|
388
|
+
head: ScreenHead,
|
|
389
|
+
lines: AsyncIterator<string>,
|
|
390
|
+
): AsyncIterable<IslandEntry> {
|
|
391
|
+
const waiting = new Map(
|
|
392
|
+
head.islands.filter((e) => e.status === "pending").map((e) => [e.key, e]),
|
|
393
|
+
);
|
|
394
|
+
try {
|
|
395
|
+
while (waiting.size > 0) {
|
|
396
|
+
const next = await lines.next();
|
|
397
|
+
if (next.done) {
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
const line = parseJson(next.value);
|
|
401
|
+
if (!isRecord(line) || line.kind === "end") {
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
// Lines of a kind it doesn't know, or for an island that isn't pending, are skipped (spec §4.6).
|
|
405
|
+
const entry = line.kind === "island" && isRecord(line.island) ? line.island : undefined;
|
|
406
|
+
if (entry?.status !== "pending" && isIslandEntry(entry) && waiting.delete(entry.key)) {
|
|
407
|
+
yield entry;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
} finally {
|
|
411
|
+
await lines.return?.();
|
|
412
|
+
}
|
|
413
|
+
for (const e of waiting.values()) {
|
|
414
|
+
yield {
|
|
415
|
+
key: e.key,
|
|
416
|
+
type: e.type,
|
|
417
|
+
version: e.version,
|
|
418
|
+
status: "error",
|
|
419
|
+
error: { code: "incomplete", retryable: true },
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** A body's lines, as they arrive if the body can be read as a stream, or all at once. */
|
|
425
|
+
async function* linesOf(res: Response): AsyncIterable<string> {
|
|
426
|
+
if (!res.body) {
|
|
427
|
+
yield* (await res.text()).split("\n").filter((line) => line !== "");
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const reader = res.body.getReader();
|
|
431
|
+
const decoder = new TextDecoder();
|
|
432
|
+
let buffered = "";
|
|
433
|
+
try {
|
|
434
|
+
for (;;) {
|
|
435
|
+
const { done, value } = await reader.read();
|
|
436
|
+
if (done) {
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
buffered += decoder.decode(value, { stream: true });
|
|
440
|
+
const lines = buffered.split("\n");
|
|
441
|
+
buffered = lines.pop() ?? "";
|
|
442
|
+
yield* lines.filter((line) => line !== "");
|
|
443
|
+
}
|
|
444
|
+
buffered += decoder.decode();
|
|
445
|
+
if (buffered.trim() !== "") {
|
|
446
|
+
yield buffered;
|
|
447
|
+
}
|
|
448
|
+
} finally {
|
|
449
|
+
await reader.cancel().catch(() => undefined);
|
|
221
450
|
}
|
|
222
|
-
return response;
|
|
223
451
|
}
|
|
224
452
|
|
|
225
453
|
// ---- Island requests (spec §4.5) ----
|
package/src/proxy.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
PROTOCOL_VERSION,
|
|
24
24
|
type ScreenRequest,
|
|
25
25
|
type ScreenResponse,
|
|
26
|
+
type StringsResponse,
|
|
26
27
|
} from "@client-compose/protocol";
|
|
27
28
|
|
|
28
29
|
import {
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
IslandFetchError,
|
|
34
35
|
ScreenFetchError,
|
|
35
36
|
} from "./index.ts";
|
|
37
|
+
import { fetchStrings, StringsFetchError } from "./strings.ts";
|
|
36
38
|
|
|
37
39
|
/** A screen request from the browser: the server sets the client context. */
|
|
38
40
|
export type ProxiedScreenRequest = Omit<ScreenRequest, "client">;
|
|
@@ -76,6 +78,8 @@ export interface ComposerProxy {
|
|
|
76
78
|
* server's context and headers. Resolves with the island's entry.
|
|
77
79
|
*/
|
|
78
80
|
loadIsland(body: unknown): Promise<ProxyResult<IslandEntry>>;
|
|
81
|
+
/** Loads the app strings (spec §4.7) in the server's client context's language. */
|
|
82
|
+
loadStrings(): Promise<ProxyResult<StringsResponse>>;
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -221,9 +225,33 @@ export function createComposerProxy(options: ComposerProxyOptions): ComposerProx
|
|
|
221
225
|
}
|
|
222
226
|
}
|
|
223
227
|
|
|
228
|
+
async function loadStrings(): Promise<ProxyResult<StringsResponse>> {
|
|
229
|
+
try {
|
|
230
|
+
return {
|
|
231
|
+
ok: true,
|
|
232
|
+
response: await fetchStrings(
|
|
233
|
+
options.baseUrl,
|
|
234
|
+
await options.client(),
|
|
235
|
+
await init(),
|
|
236
|
+
options.fetch,
|
|
237
|
+
),
|
|
238
|
+
};
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error instanceof StringsFetchError) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
status: error.status,
|
|
244
|
+
body: error.body,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
224
251
|
return {
|
|
225
252
|
performAction,
|
|
226
253
|
loadScreen,
|
|
227
254
|
loadIsland,
|
|
255
|
+
loadStrings,
|
|
228
256
|
};
|
|
229
257
|
}
|
package/src/strings.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Strings (spec §4.7): the words a client shows, from the composer. Templates are filled in here,
|
|
2
|
+
// counted strings pick their variant by the language's plural rules, and an island's strings
|
|
3
|
+
// become the typed `t` its renderer receives.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type AppStringsDescriptor,
|
|
7
|
+
type ClientContext,
|
|
8
|
+
type PluralRules,
|
|
9
|
+
PROTOCOL_VERSION,
|
|
10
|
+
type StringParams,
|
|
11
|
+
type Strings,
|
|
12
|
+
type StringsResponse,
|
|
13
|
+
type StringTemplate,
|
|
14
|
+
} from "@client-compose/protocol";
|
|
15
|
+
|
|
16
|
+
// ---- Plural rules ----
|
|
17
|
+
|
|
18
|
+
/** A count's plural category in a language: the first whose condition holds, or "other". */
|
|
19
|
+
export function pluralCategory(rules: PluralRules | undefined, count: number): string {
|
|
20
|
+
for (const [category, condition] of Object.entries(rules ?? {})) {
|
|
21
|
+
if (condition !== undefined && holds(condition, count)) {
|
|
22
|
+
return category;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return "other";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a CLDR plural condition holds for a whole number: `or` of `and`s of relations like
|
|
30
|
+
* `i % 10 = 2..4` or `v = 0`. For a whole number, `n` and `i` are the number, and `v`, `w`, `f`,
|
|
31
|
+
* `t`, `c` and `e` are zero.
|
|
32
|
+
*/
|
|
33
|
+
function holds(condition: string, count: number): boolean {
|
|
34
|
+
return condition.split(" or ").some((all) => all.split(" and ").every((r) => relates(r, count)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const RELATION = /^\s*([nivwftce])\s*(?:%\s*(\d+))?\s*(!=|=)\s*(.+?)\s*$/;
|
|
38
|
+
|
|
39
|
+
function relates(relation: string, count: number): boolean {
|
|
40
|
+
const match = RELATION.exec(relation);
|
|
41
|
+
if (!match) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const [, operand = "", modulus, op, ranges = ""] = match;
|
|
45
|
+
const base = operand === "n" || operand === "i" ? Math.abs(Math.trunc(count)) : 0;
|
|
46
|
+
const value = modulus === undefined ? base : base % Number(modulus);
|
|
47
|
+
const inRanges = ranges.split(",").some((range) => {
|
|
48
|
+
const [from = "", to] = range.trim().split("..");
|
|
49
|
+
return to === undefined ? value === Number(from) : value >= Number(from) && value <= Number(to);
|
|
50
|
+
});
|
|
51
|
+
return op === "=" ? inRanges : !inRanges;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- Templates ----
|
|
55
|
+
|
|
56
|
+
/** The values a string's params take. A counted string's `count` picks its variant. */
|
|
57
|
+
export type StringValues = Record<string, string | number>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A string's words with its params filled in. Variants are chosen by the exact count, then its
|
|
61
|
+
* plural category, then `#*`.
|
|
62
|
+
*/
|
|
63
|
+
export function formatString(
|
|
64
|
+
template: StringTemplate | undefined,
|
|
65
|
+
values: StringValues = {},
|
|
66
|
+
plurals?: PluralRules,
|
|
67
|
+
): string {
|
|
68
|
+
if (template === undefined) {
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
const text = typeof template === "string" ? template : variant(template, values.count, plurals);
|
|
72
|
+
return text.replace(/\{([A-Za-z0-9]+)\}/g, (slot, name: string) =>
|
|
73
|
+
name in values ? String(values[name]) : slot,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function variant(
|
|
78
|
+
variants: Record<string, string>,
|
|
79
|
+
count: string | number | undefined,
|
|
80
|
+
plurals: PluralRules | undefined,
|
|
81
|
+
): string {
|
|
82
|
+
const n = Number(count);
|
|
83
|
+
if (Number.isFinite(n)) {
|
|
84
|
+
const exact = variants[`#${n}`];
|
|
85
|
+
if (exact !== undefined) {
|
|
86
|
+
return exact;
|
|
87
|
+
}
|
|
88
|
+
const category = variants[`#${pluralCategory(plurals, n)}`];
|
|
89
|
+
if (category !== undefined) {
|
|
90
|
+
return category;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return variants["#*"] ?? "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Strings as a renderer receives them: each declared string with no params as its text, each with
|
|
98
|
+
* params as a function filling them in. A string the composer didn't send is empty.
|
|
99
|
+
*/
|
|
100
|
+
export function buildStrings<S>(
|
|
101
|
+
declared: StringParams,
|
|
102
|
+
strings: Strings | undefined,
|
|
103
|
+
plurals: PluralRules | undefined,
|
|
104
|
+
): S {
|
|
105
|
+
const t: Record<string, string | ((values: StringValues) => string)> = {};
|
|
106
|
+
for (const [key, params] of Object.entries(declared)) {
|
|
107
|
+
const template = strings?.[key];
|
|
108
|
+
t[key] =
|
|
109
|
+
params.length === 0
|
|
110
|
+
? formatString(template, {}, plurals)
|
|
111
|
+
: (values: StringValues) => formatString(template, values, plurals);
|
|
112
|
+
}
|
|
113
|
+
// The declared keys and params are the ones codegen typed S with.
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
115
|
+
return t as S;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- App strings ----
|
|
119
|
+
|
|
120
|
+
/** The app strings, typed by their generated descriptor: `appStrings(AppStrings, response).retry`. */
|
|
121
|
+
export function appStrings<S>(descriptor: AppStringsDescriptor<S>, response: StringsResponse): S {
|
|
122
|
+
return buildStrings<S>(descriptor.strings, response.strings, response.plurals);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Thrown for a strings request the composer didn't answer with 200 and its strings. */
|
|
126
|
+
export class StringsFetchError extends Error {
|
|
127
|
+
readonly status: number;
|
|
128
|
+
readonly body: string;
|
|
129
|
+
|
|
130
|
+
constructor(status: number, body: string) {
|
|
131
|
+
super(`Strings request failed with status ${status}`);
|
|
132
|
+
this.status = status;
|
|
133
|
+
this.body = body;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
138
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
139
|
+
|
|
140
|
+
function isStringsResponse(value: unknown): value is StringsResponse {
|
|
141
|
+
if (!isRecord(value) || typeof value.locale !== "string") {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
return isRecord(value.plurals) && isRecord(value.strings);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Fetch the app strings in the client's language (spec §4.7). */
|
|
148
|
+
export async function fetchStrings(
|
|
149
|
+
baseUrl: string,
|
|
150
|
+
client: ClientContext,
|
|
151
|
+
init: RequestInit = {},
|
|
152
|
+
fetchImpl: typeof fetch = fetch,
|
|
153
|
+
): Promise<StringsResponse> {
|
|
154
|
+
const headers = new Headers(init.headers);
|
|
155
|
+
if (!headers.has("content-type")) {
|
|
156
|
+
headers.set("content-type", "application/json");
|
|
157
|
+
}
|
|
158
|
+
const res = await fetchImpl(`${baseUrl}/strings`, {
|
|
159
|
+
...init,
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers,
|
|
162
|
+
body: JSON.stringify({ protocol: PROTOCOL_VERSION, client }),
|
|
163
|
+
});
|
|
164
|
+
const text = await res.text();
|
|
165
|
+
if (!res.ok) {
|
|
166
|
+
throw new StringsFetchError(res.status, text);
|
|
167
|
+
}
|
|
168
|
+
let parsed: unknown;
|
|
169
|
+
try {
|
|
170
|
+
parsed = JSON.parse(text);
|
|
171
|
+
} catch {
|
|
172
|
+
throw new StringsFetchError(res.status, text);
|
|
173
|
+
}
|
|
174
|
+
if (!isStringsResponse(parsed)) {
|
|
175
|
+
throw new StringsFetchError(res.status, text);
|
|
176
|
+
}
|
|
177
|
+
return parsed;
|
|
178
|
+
}
|