@olenbetong/appframe-core 2.13.1 → 2.14.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/dist/esm/af.common.js +77 -20
- package/dist/esm/af.common.legacy.js +73 -16
- package/dist/esm/af.common.legacy.min.js +3 -3
- package/dist/esm/af.common.legacy.min.js.map +1 -1
- package/dist/esm/af.common.min.js +3 -3
- package/dist/esm/af.common.min.js.map +1 -1
- package/dist/iife/af.common.js +77 -19
- package/dist/iife/af.common.legacy.js +73 -15
- package/dist/iife/af.common.legacy.min.js +3 -3
- package/dist/iife/af.common.legacy.min.js.map +1 -1
- package/dist/iife/af.common.min.js +3 -3
- package/dist/iife/af.common.min.js.map +1 -1
- package/es/getLocalizedString.d.ts +63 -1
- package/es/getLocalizedString.js +89 -18
- package/es/getLocalizedString.test.d.ts +1 -0
- package/es/getLocalizedString.test.js +66 -0
- package/es/index.d.ts +7 -1
- package/es/index.js +2 -2
- package/package.json +4 -4
- package/src/getLocalizedString.test.ts +85 -0
- package/src/getLocalizedString.ts +123 -19
- package/src/index.ts +7 -1
|
@@ -1,7 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localized strings come from two places on a page:
|
|
3
|
+
*
|
|
4
|
+
* - `af.article.i18n`: the strings the article's project uses, rendered by the framework
|
|
5
|
+
* into every page of that article. A string missing there is harvested by posting it to
|
|
6
|
+
* `/api/user/localize/new/<article>`, which registers it as used by the article and
|
|
7
|
+
* returns the translation.
|
|
8
|
+
* - `af.i18n[<project>]`: the strings of another project, for code that runs on every
|
|
9
|
+
* page without belonging to any one article - the site chrome bundle, the shared
|
|
10
|
+
* component packages. Such a project is a name, not an article; the harvest endpoint
|
|
11
|
+
* accepts any name, so `localize/new/<project>` registers the string under that project.
|
|
12
|
+
*
|
|
13
|
+
* Keeping a project's strings apart from the article's matters when a word means
|
|
14
|
+
* different things: "Articles" is "Artikler" in the chrome and "Varer" in an accounting
|
|
15
|
+
* app, which the framework's project-specific base strings express only if the two are
|
|
16
|
+
* looked up in different dictionaries.
|
|
17
|
+
*
|
|
18
|
+
* A project's dictionary is in play only when something put it there: the template
|
|
19
|
+
* rendered it (`@Render("ChromeStrings", Project: "...")`), or the caller fetched it and
|
|
20
|
+
* assigned `af.i18n[project]` itself (an empty object counts). On a page that did neither -
|
|
21
|
+
* a legacy template - the project falls back to the article's dictionary and the article's
|
|
22
|
+
* harvesting, so the page behaves as it always did instead of posting every string of a
|
|
23
|
+
* grid on every load. The project fills up from the pages that render it.
|
|
24
|
+
*/
|
|
25
|
+
export type LocalizeOptions = {
|
|
26
|
+
/**
|
|
27
|
+
* The globalization project the string belongs to. Defaults to the article, whose
|
|
28
|
+
* strings the framework renders into `af.article.i18n`. Falls back to the article when
|
|
29
|
+
* the page carries no dictionary for the project (see the module comment).
|
|
30
|
+
*/
|
|
31
|
+
project?: string;
|
|
32
|
+
/** Resolve with the translation once it has been fetched, instead of the base text at once. */
|
|
33
|
+
wait?: boolean;
|
|
34
|
+
};
|
|
1
35
|
export declare function getLocalizedStringAsync(string: undefined | null): undefined | null;
|
|
2
|
-
export declare function getLocalizedStringAsync(string: string): Promise<string>;
|
|
36
|
+
export declare function getLocalizedStringAsync(string: string, options?: Omit<LocalizeOptions, "wait">): Promise<string>;
|
|
3
37
|
export declare function getLocalizedString(string: undefined | null): undefined | null;
|
|
4
38
|
export declare function getLocalizedString(string: string): string;
|
|
5
39
|
export declare function getLocalizedString(string: string, wait: false): string;
|
|
6
40
|
export declare function getLocalizedString(string: string, wait: true): Promise<string>;
|
|
7
41
|
export declare function getLocalizedString(string: undefined | null, wait: true): undefined | null;
|
|
42
|
+
export declare function getLocalizedString(string: string, options: LocalizeOptions & {
|
|
43
|
+
wait: true;
|
|
44
|
+
}): Promise<string>;
|
|
45
|
+
export declare function getLocalizedString(string: string, options: LocalizeOptions): string;
|
|
46
|
+
export declare function getLocalizedString(string: undefined | null, options: LocalizeOptions): undefined | null;
|
|
47
|
+
/**
|
|
48
|
+
* `getLocalizedString` and `localize` bound to a globalization project, for code that is
|
|
49
|
+
* not part of the article it runs on: the site chrome, the shared component packages.
|
|
50
|
+
*
|
|
51
|
+
* On a page whose template renders the project (`af.i18n[project]` exists) the strings are
|
|
52
|
+
* looked up and harvested there; on any other page they resolve through the article's
|
|
53
|
+
* dictionary as a plain `getLocalizedString` would.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* const { getLocalizedString: t } = createLocalizer("synergi-chrome");
|
|
58
|
+
* t("Applications");
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export declare function createLocalizer(project: string): {
|
|
62
|
+
project: string;
|
|
63
|
+
getLocalizedString: {
|
|
64
|
+
(string: undefined | null): undefined | null;
|
|
65
|
+
(string: string): string;
|
|
66
|
+
(string: string, wait: true): Promise<string>;
|
|
67
|
+
};
|
|
68
|
+
localize: (stringParts: TemplateStringsArray, ...args: unknown[]) => string;
|
|
69
|
+
};
|
package/es/getLocalizedString.js
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Localized strings come from two places on a page:
|
|
3
|
+
*
|
|
4
|
+
* - `af.article.i18n`: the strings the article's project uses, rendered by the framework
|
|
5
|
+
* into every page of that article. A string missing there is harvested by posting it to
|
|
6
|
+
* `/api/user/localize/new/<article>`, which registers it as used by the article and
|
|
7
|
+
* returns the translation.
|
|
8
|
+
* - `af.i18n[<project>]`: the strings of another project, for code that runs on every
|
|
9
|
+
* page without belonging to any one article - the site chrome bundle, the shared
|
|
10
|
+
* component packages. Such a project is a name, not an article; the harvest endpoint
|
|
11
|
+
* accepts any name, so `localize/new/<project>` registers the string under that project.
|
|
12
|
+
*
|
|
13
|
+
* Keeping a project's strings apart from the article's matters when a word means
|
|
14
|
+
* different things: "Articles" is "Artikler" in the chrome and "Varer" in an accounting
|
|
15
|
+
* app, which the framework's project-specific base strings express only if the two are
|
|
16
|
+
* looked up in different dictionaries.
|
|
17
|
+
*
|
|
18
|
+
* A project's dictionary is in play only when something put it there: the template
|
|
19
|
+
* rendered it (`@Render("ChromeStrings", Project: "...")`), or the caller fetched it and
|
|
20
|
+
* assigned `af.i18n[project]` itself (an empty object counts). On a page that did neither -
|
|
21
|
+
* a legacy template - the project falls back to the article's dictionary and the article's
|
|
22
|
+
* harvesting, so the page behaves as it always did instead of posting every string of a
|
|
23
|
+
* grid on every load. The project fills up from the pages that render it.
|
|
24
|
+
*/
|
|
25
|
+
async function fetchLocalizedString(text, project) {
|
|
2
26
|
try {
|
|
3
|
-
let response = await fetch(`/api/user/localize/new/${
|
|
27
|
+
let response = await fetch(`/api/user/localize/new/${encodeURIComponent(project)}`, {
|
|
4
28
|
body: JSON.stringify({ text }),
|
|
5
29
|
headers: {
|
|
6
30
|
Accept: "application/json; */*",
|
|
@@ -16,32 +40,79 @@ async function fetchLocalizedString(text) {
|
|
|
16
40
|
catch { }
|
|
17
41
|
return text;
|
|
18
42
|
}
|
|
19
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The dictionary a string is resolved in: the project's when the page carries one,
|
|
45
|
+
* otherwise the article's; nothing when the page has no `af` at all.
|
|
46
|
+
*/
|
|
47
|
+
function sourceFor(project) {
|
|
48
|
+
let af = globalThis.af;
|
|
49
|
+
if (!af)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (project) {
|
|
52
|
+
let dictionary = af.i18n?.[project];
|
|
53
|
+
if (dictionary)
|
|
54
|
+
return { dictionary, harvestAs: project };
|
|
55
|
+
}
|
|
56
|
+
let article = af.article;
|
|
57
|
+
if (!article?.i18n)
|
|
58
|
+
return undefined;
|
|
59
|
+
return { dictionary: article.i18n, harvestAs: article.id };
|
|
60
|
+
}
|
|
61
|
+
export function getLocalizedStringAsync(string, options) {
|
|
20
62
|
if (!string)
|
|
21
63
|
return string;
|
|
22
|
-
return getLocalizedString(string, true);
|
|
64
|
+
return getLocalizedString(string, { ...options, wait: true });
|
|
23
65
|
}
|
|
24
|
-
export function getLocalizedString(string,
|
|
66
|
+
export function getLocalizedString(string, waitOrOptions) {
|
|
25
67
|
if (typeof string === "string" && string.trim().length === 0) {
|
|
26
68
|
return string;
|
|
27
69
|
}
|
|
28
70
|
if (string === undefined || string === "" || string === null) {
|
|
29
71
|
return;
|
|
30
72
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
73
|
+
let { project, wait } = typeof waitOrOptions === "object" ? waitOrOptions : { wait: waitOrOptions };
|
|
74
|
+
let source = sourceFor(project);
|
|
75
|
+
if (source === undefined) {
|
|
76
|
+
return string;
|
|
77
|
+
}
|
|
78
|
+
let { dictionary, harvestAs } = source;
|
|
79
|
+
if (!Object.hasOwn(dictionary, string)) {
|
|
80
|
+
dictionary[string] = string;
|
|
81
|
+
let promise = fetchLocalizedString(string, harvestAs).then((text) => {
|
|
82
|
+
dictionary[string] = text;
|
|
83
|
+
return text;
|
|
84
|
+
});
|
|
85
|
+
if (wait) {
|
|
86
|
+
return promise;
|
|
41
87
|
}
|
|
42
|
-
return af.article.i18n[string];
|
|
43
88
|
}
|
|
44
|
-
|
|
45
|
-
|
|
89
|
+
return dictionary[string];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* `getLocalizedString` and `localize` bound to a globalization project, for code that is
|
|
93
|
+
* not part of the article it runs on: the site chrome, the shared component packages.
|
|
94
|
+
*
|
|
95
|
+
* On a page whose template renders the project (`af.i18n[project]` exists) the strings are
|
|
96
|
+
* looked up and harvested there; on any other page they resolve through the article's
|
|
97
|
+
* dictionary as a plain `getLocalizedString` would.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* const { getLocalizedString: t } = createLocalizer("synergi-chrome");
|
|
102
|
+
* t("Applications");
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export function createLocalizer(project) {
|
|
106
|
+
function bound(string, wait) {
|
|
107
|
+
return getLocalizedString(string, { project, wait });
|
|
108
|
+
}
|
|
109
|
+
function localize(stringParts, ...args) {
|
|
110
|
+
let stringToLocalize = stringParts.map((s, i) => (i === stringParts.length - 1 ? s : `${s}$${i}`)).join("");
|
|
111
|
+
let localized = bound(stringToLocalize);
|
|
112
|
+
args.forEach((arg, index) => {
|
|
113
|
+
localized = localized.replace(`$${index}`, String(arg));
|
|
114
|
+
});
|
|
115
|
+
return localized;
|
|
46
116
|
}
|
|
117
|
+
return { project, getLocalizedString: bound, localize };
|
|
47
118
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { createLocalizer, getLocalizedString } from "./getLocalizedString.js";
|
|
3
|
+
function fetchAnswering(translations) {
|
|
4
|
+
return vi.fn(async (_url, init) => {
|
|
5
|
+
let { text } = JSON.parse(init.body);
|
|
6
|
+
return { ok: true, json: async () => translations[text] ?? text };
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
function harvestedProjects(fetchMock) {
|
|
10
|
+
return fetchMock.mock.calls.map(([url]) => url.replace("/api/user/localize/new/", ""));
|
|
11
|
+
}
|
|
12
|
+
describe("getLocalizedString", () => {
|
|
13
|
+
let af;
|
|
14
|
+
let fetchMock;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
af = { article: { id: "my-article", i18n: { Save: "Lagre" } } };
|
|
17
|
+
globalThis.af = af;
|
|
18
|
+
fetchMock = fetchAnswering({ Cancel: "Avbryt", Articles: "Artikler" });
|
|
19
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.unstubAllGlobals();
|
|
23
|
+
delete globalThis.af;
|
|
24
|
+
});
|
|
25
|
+
test("resolves in the article's dictionary and harvests under the article", async () => {
|
|
26
|
+
expect(getLocalizedString("Save")).toBe("Lagre");
|
|
27
|
+
expect(getLocalizedString("Cancel")).toBe("Cancel");
|
|
28
|
+
await vi.waitFor(() => expect(af.article.i18n.Cancel).toBe("Avbryt"));
|
|
29
|
+
expect(harvestedProjects(fetchMock)).toEqual(["my-article"]);
|
|
30
|
+
});
|
|
31
|
+
test("uses the project's dictionary when the page rendered it", async () => {
|
|
32
|
+
af.i18n = { "synergi-chrome": { Articles: "Artikler" } };
|
|
33
|
+
af.article.i18n.Articles = "Varer";
|
|
34
|
+
expect(getLocalizedString("Articles", { project: "synergi-chrome" })).toBe("Artikler");
|
|
35
|
+
expect(getLocalizedString("Articles")).toBe("Varer");
|
|
36
|
+
expect(await getLocalizedString("Cancel", { project: "synergi-chrome", wait: true })).toBe("Avbryt");
|
|
37
|
+
expect(af.i18n["synergi-chrome"].Cancel).toBe("Avbryt");
|
|
38
|
+
expect(af.article.i18n.Cancel).toBeUndefined();
|
|
39
|
+
expect(harvestedProjects(fetchMock)).toEqual(["synergi-chrome"]);
|
|
40
|
+
});
|
|
41
|
+
test("an empty rendered dictionary still means the project is in play", async () => {
|
|
42
|
+
af.i18n = { "appframe-ds": {} };
|
|
43
|
+
expect(await getLocalizedString("Cancel", { project: "appframe-ds", wait: true })).toBe("Avbryt");
|
|
44
|
+
expect(harvestedProjects(fetchMock)).toEqual(["appframe-ds"]);
|
|
45
|
+
});
|
|
46
|
+
test("falls back to the article when the page carries no dictionary for the project", async () => {
|
|
47
|
+
expect(getLocalizedString("Save", { project: "appframe-ds" })).toBe("Lagre");
|
|
48
|
+
expect(await getLocalizedString("Cancel", { project: "appframe-ds", wait: true })).toBe("Avbryt");
|
|
49
|
+
expect(af.i18n).toBeUndefined();
|
|
50
|
+
expect(af.article.i18n.Cancel).toBe("Avbryt");
|
|
51
|
+
expect(harvestedProjects(fetchMock)).toEqual(["my-article"]);
|
|
52
|
+
});
|
|
53
|
+
test("returns the base text when nothing is on the page", () => {
|
|
54
|
+
delete globalThis.af;
|
|
55
|
+
expect(getLocalizedString("Save", { project: "appframe-ds" })).toBe("Save");
|
|
56
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
test("createLocalizer binds the project for getLocalizedString and localize", () => {
|
|
59
|
+
af.i18n = { "synergi-chrome": { Articles: "Artikler", "Hello $0": "Hei $0" } };
|
|
60
|
+
let { getLocalizedString: t, localize, project } = createLocalizer("synergi-chrome");
|
|
61
|
+
expect(project).toBe("synergi-chrome");
|
|
62
|
+
expect(t("Articles")).toBe("Artikler");
|
|
63
|
+
expect(localize `Hello ${"Ola"}`).toBe("Hei Ola");
|
|
64
|
+
expect(t(null)).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
});
|
package/es/index.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export type { DownloadItem, DownloadParameters } from "./downloadRequest.js";
|
|
|
13
13
|
export { downloadRequest } from "./downloadRequest.js";
|
|
14
14
|
export { expose } from "./expose.js";
|
|
15
15
|
export * from "./filter/index.js";
|
|
16
|
-
export { getLocalizedString } from "./getLocalizedString.js";
|
|
16
|
+
export { createLocalizer, getLocalizedString, type LocalizeOptions } from "./getLocalizedString.js";
|
|
17
17
|
export type { getUserImageOptions } from "./getUserImage.js";
|
|
18
18
|
export { getUserImage } from "./getUserImage.js";
|
|
19
19
|
export { invariant } from "./invariant.js";
|
|
@@ -43,6 +43,12 @@ export interface AfArticle {
|
|
|
43
43
|
}
|
|
44
44
|
export interface AfCore {
|
|
45
45
|
article: AfArticle;
|
|
46
|
+
/**
|
|
47
|
+
* Strings of globalization projects other than the article's, by project name, for
|
|
48
|
+
* code that runs on every page without belonging to an article (the site chrome).
|
|
49
|
+
* Rendered server side or filled by the caller; see `getLocalizedString`'s `project`.
|
|
50
|
+
*/
|
|
51
|
+
i18n?: Record<string, Record<string, string>>;
|
|
46
52
|
common: {
|
|
47
53
|
getLocalizedString: typeof getLocalizedString;
|
|
48
54
|
expose: typeof expose;
|
package/es/index.js
CHANGED
|
@@ -7,7 +7,7 @@ export { downloadBlob } from "./downloadBlob.js";
|
|
|
7
7
|
export { downloadRequest } from "./downloadRequest.js";
|
|
8
8
|
export { expose } from "./expose.js";
|
|
9
9
|
export * from "./filter/index.js";
|
|
10
|
-
export { getLocalizedString } from "./getLocalizedString.js";
|
|
10
|
+
export { createLocalizer, getLocalizedString } from "./getLocalizedString.js";
|
|
11
11
|
export { getUserImage } from "./getUserImage.js";
|
|
12
12
|
export { invariant } from "./invariant.js";
|
|
13
13
|
export { localize } from "./localize.js";
|
|
@@ -19,4 +19,4 @@ export { UserSession };
|
|
|
19
19
|
export { alert, ConfirmButtons, confirm, openAlertDialog, waitForDialog } from "./alert.js";
|
|
20
20
|
export { getSettings, saveSettings } from "./settings.js";
|
|
21
21
|
// oxlint-disable-next-line no-use-before-define -- this is injected build time
|
|
22
|
-
export const version = "2.
|
|
22
|
+
export const version = "2.14.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olenbetong/appframe-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"description": "Javascript libraries to utilize the Appframe framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"mitt": "^3.0.1"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@happy-dom/global-registrator": "
|
|
44
|
-
"@types/bun": "^1.
|
|
45
|
-
"chalk": "^
|
|
43
|
+
"@happy-dom/global-registrator": "20.14.5",
|
|
44
|
+
"@types/bun": "^1.4.2",
|
|
45
|
+
"chalk": "^6.0.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {},
|
|
48
48
|
"optionalDependencies": {},
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createLocalizer, getLocalizedString } from "./getLocalizedString.js";
|
|
4
|
+
|
|
5
|
+
type Af = { article: { id: string; i18n: Record<string, string> }; i18n?: Record<string, Record<string, string>> };
|
|
6
|
+
|
|
7
|
+
function fetchAnswering(translations: Record<string, string>) {
|
|
8
|
+
return vi.fn(async (_url: string, init: RequestInit) => {
|
|
9
|
+
let { text } = JSON.parse(init.body as string) as { text: string };
|
|
10
|
+
return { ok: true, json: async () => translations[text] ?? text };
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function harvestedProjects(fetchMock: ReturnType<typeof fetchAnswering>) {
|
|
15
|
+
return fetchMock.mock.calls.map(([url]) => (url as string).replace("/api/user/localize/new/", ""));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("getLocalizedString", () => {
|
|
19
|
+
let af: Af;
|
|
20
|
+
let fetchMock: ReturnType<typeof fetchAnswering>;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
af = { article: { id: "my-article", i18n: { Save: "Lagre" } } };
|
|
24
|
+
(globalThis as { af?: Af }).af = af;
|
|
25
|
+
fetchMock = fetchAnswering({ Cancel: "Avbryt", Articles: "Artikler" });
|
|
26
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.unstubAllGlobals();
|
|
31
|
+
delete (globalThis as { af?: Af }).af;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("resolves in the article's dictionary and harvests under the article", async () => {
|
|
35
|
+
expect(getLocalizedString("Save")).toBe("Lagre");
|
|
36
|
+
expect(getLocalizedString("Cancel")).toBe("Cancel");
|
|
37
|
+
await vi.waitFor(() => expect(af.article.i18n.Cancel).toBe("Avbryt"));
|
|
38
|
+
expect(harvestedProjects(fetchMock)).toEqual(["my-article"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("uses the project's dictionary when the page rendered it", async () => {
|
|
42
|
+
af.i18n = { "synergi-chrome": { Articles: "Artikler" } };
|
|
43
|
+
af.article.i18n.Articles = "Varer";
|
|
44
|
+
|
|
45
|
+
expect(getLocalizedString("Articles", { project: "synergi-chrome" })).toBe("Artikler");
|
|
46
|
+
expect(getLocalizedString("Articles")).toBe("Varer");
|
|
47
|
+
|
|
48
|
+
expect(await getLocalizedString("Cancel", { project: "synergi-chrome", wait: true })).toBe("Avbryt");
|
|
49
|
+
expect(af.i18n["synergi-chrome"].Cancel).toBe("Avbryt");
|
|
50
|
+
expect(af.article.i18n.Cancel).toBeUndefined();
|
|
51
|
+
expect(harvestedProjects(fetchMock)).toEqual(["synergi-chrome"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("an empty rendered dictionary still means the project is in play", async () => {
|
|
55
|
+
af.i18n = { "appframe-ds": {} };
|
|
56
|
+
|
|
57
|
+
expect(await getLocalizedString("Cancel", { project: "appframe-ds", wait: true })).toBe("Avbryt");
|
|
58
|
+
expect(harvestedProjects(fetchMock)).toEqual(["appframe-ds"]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("falls back to the article when the page carries no dictionary for the project", async () => {
|
|
62
|
+
expect(getLocalizedString("Save", { project: "appframe-ds" })).toBe("Lagre");
|
|
63
|
+
expect(await getLocalizedString("Cancel", { project: "appframe-ds", wait: true })).toBe("Avbryt");
|
|
64
|
+
|
|
65
|
+
expect(af.i18n).toBeUndefined();
|
|
66
|
+
expect(af.article.i18n.Cancel).toBe("Avbryt");
|
|
67
|
+
expect(harvestedProjects(fetchMock)).toEqual(["my-article"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("returns the base text when nothing is on the page", () => {
|
|
71
|
+
delete (globalThis as { af?: Af }).af;
|
|
72
|
+
expect(getLocalizedString("Save", { project: "appframe-ds" })).toBe("Save");
|
|
73
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("createLocalizer binds the project for getLocalizedString and localize", () => {
|
|
77
|
+
af.i18n = { "synergi-chrome": { Articles: "Artikler", "Hello $0": "Hei $0" } };
|
|
78
|
+
let { getLocalizedString: t, localize, project } = createLocalizer("synergi-chrome");
|
|
79
|
+
|
|
80
|
+
expect(project).toBe("synergi-chrome");
|
|
81
|
+
expect(t("Articles")).toBe("Artikler");
|
|
82
|
+
expect(localize`Hello ${"Ola"}`).toBe("Hei Ola");
|
|
83
|
+
expect(t(null)).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -1,6 +1,42 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Localized strings come from two places on a page:
|
|
3
|
+
*
|
|
4
|
+
* - `af.article.i18n`: the strings the article's project uses, rendered by the framework
|
|
5
|
+
* into every page of that article. A string missing there is harvested by posting it to
|
|
6
|
+
* `/api/user/localize/new/<article>`, which registers it as used by the article and
|
|
7
|
+
* returns the translation.
|
|
8
|
+
* - `af.i18n[<project>]`: the strings of another project, for code that runs on every
|
|
9
|
+
* page without belonging to any one article - the site chrome bundle, the shared
|
|
10
|
+
* component packages. Such a project is a name, not an article; the harvest endpoint
|
|
11
|
+
* accepts any name, so `localize/new/<project>` registers the string under that project.
|
|
12
|
+
*
|
|
13
|
+
* Keeping a project's strings apart from the article's matters when a word means
|
|
14
|
+
* different things: "Articles" is "Artikler" in the chrome and "Varer" in an accounting
|
|
15
|
+
* app, which the framework's project-specific base strings express only if the two are
|
|
16
|
+
* looked up in different dictionaries.
|
|
17
|
+
*
|
|
18
|
+
* A project's dictionary is in play only when something put it there: the template
|
|
19
|
+
* rendered it (`@Render("ChromeStrings", Project: "...")`), or the caller fetched it and
|
|
20
|
+
* assigned `af.i18n[project]` itself (an empty object counts). On a page that did neither -
|
|
21
|
+
* a legacy template - the project falls back to the article's dictionary and the article's
|
|
22
|
+
* harvesting, so the page behaves as it always did instead of posting every string of a
|
|
23
|
+
* grid on every load. The project fills up from the pages that render it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type LocalizeOptions = {
|
|
27
|
+
/**
|
|
28
|
+
* The globalization project the string belongs to. Defaults to the article, whose
|
|
29
|
+
* strings the framework renders into `af.article.i18n`. Falls back to the article when
|
|
30
|
+
* the page carries no dictionary for the project (see the module comment).
|
|
31
|
+
*/
|
|
32
|
+
project?: string;
|
|
33
|
+
/** Resolve with the translation once it has been fetched, instead of the base text at once. */
|
|
34
|
+
wait?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
async function fetchLocalizedString(text: string, project: string) {
|
|
2
38
|
try {
|
|
3
|
-
let response = await fetch(`/api/user/localize/new/${
|
|
39
|
+
let response = await fetch(`/api/user/localize/new/${encodeURIComponent(project)}`, {
|
|
4
40
|
body: JSON.stringify({ text }),
|
|
5
41
|
headers: {
|
|
6
42
|
Accept: "application/json; */*",
|
|
@@ -17,12 +53,38 @@ async function fetchLocalizedString(text: string) {
|
|
|
17
53
|
|
|
18
54
|
return text;
|
|
19
55
|
}
|
|
56
|
+
|
|
57
|
+
type Source = {
|
|
58
|
+
/** Where the string is looked up and the fetched translation is kept for the page. */
|
|
59
|
+
dictionary: Record<string, string>;
|
|
60
|
+
/** The name a missing string is harvested under: the project, or the article's id. */
|
|
61
|
+
harvestAs: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The dictionary a string is resolved in: the project's when the page carries one,
|
|
66
|
+
* otherwise the article's; nothing when the page has no `af` at all.
|
|
67
|
+
*/
|
|
68
|
+
function sourceFor(project: string | undefined): Source | undefined {
|
|
69
|
+
let af = globalThis.af;
|
|
70
|
+
if (!af) return undefined;
|
|
71
|
+
|
|
72
|
+
if (project) {
|
|
73
|
+
let dictionary = af.i18n?.[project];
|
|
74
|
+
if (dictionary) return { dictionary, harvestAs: project };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let article = af.article;
|
|
78
|
+
if (!article?.i18n) return undefined;
|
|
79
|
+
return { dictionary: article.i18n, harvestAs: article.id };
|
|
80
|
+
}
|
|
81
|
+
|
|
20
82
|
export function getLocalizedStringAsync(string: undefined | null): undefined | null;
|
|
21
|
-
export function getLocalizedStringAsync(string: string): Promise<string>;
|
|
22
|
-
export function getLocalizedStringAsync(string: string | undefined | null) {
|
|
83
|
+
export function getLocalizedStringAsync(string: string, options?: Omit<LocalizeOptions, "wait">): Promise<string>;
|
|
84
|
+
export function getLocalizedStringAsync(string: string | undefined | null, options?: Omit<LocalizeOptions, "wait">) {
|
|
23
85
|
if (!string) return string;
|
|
24
86
|
|
|
25
|
-
return getLocalizedString(string, true);
|
|
87
|
+
return getLocalizedString(string, { ...options, wait: true });
|
|
26
88
|
}
|
|
27
89
|
|
|
28
90
|
export function getLocalizedString(string: undefined | null): undefined | null;
|
|
@@ -30,9 +92,12 @@ export function getLocalizedString(string: string): string;
|
|
|
30
92
|
export function getLocalizedString(string: string, wait: false): string;
|
|
31
93
|
export function getLocalizedString(string: string, wait: true): Promise<string>;
|
|
32
94
|
export function getLocalizedString(string: undefined | null, wait: true): undefined | null;
|
|
95
|
+
export function getLocalizedString(string: string, options: LocalizeOptions & { wait: true }): Promise<string>;
|
|
96
|
+
export function getLocalizedString(string: string, options: LocalizeOptions): string;
|
|
97
|
+
export function getLocalizedString(string: undefined | null, options: LocalizeOptions): undefined | null;
|
|
33
98
|
export function getLocalizedString(
|
|
34
99
|
string: string | undefined | null,
|
|
35
|
-
|
|
100
|
+
waitOrOptions?: boolean | LocalizeOptions,
|
|
36
101
|
): string | undefined | null | Promise<string> {
|
|
37
102
|
if (typeof string === "string" && string.trim().length === 0) {
|
|
38
103
|
return string;
|
|
@@ -42,22 +107,61 @@ export function getLocalizedString(
|
|
|
42
107
|
return;
|
|
43
108
|
}
|
|
44
109
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
110
|
+
let { project, wait } = typeof waitOrOptions === "object" ? waitOrOptions : { wait: waitOrOptions };
|
|
111
|
+
let source = sourceFor(project);
|
|
112
|
+
if (source === undefined) {
|
|
113
|
+
return string;
|
|
114
|
+
}
|
|
48
115
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
});
|
|
116
|
+
let { dictionary, harvestAs } = source;
|
|
117
|
+
if (!Object.hasOwn(dictionary, string)) {
|
|
118
|
+
dictionary[string] = string;
|
|
53
119
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
120
|
+
let promise = fetchLocalizedString(string, harvestAs).then((text) => {
|
|
121
|
+
dictionary[string] = text;
|
|
122
|
+
return text;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (wait) {
|
|
126
|
+
return promise;
|
|
57
127
|
}
|
|
128
|
+
}
|
|
58
129
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
130
|
+
return dictionary[string];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* `getLocalizedString` and `localize` bound to a globalization project, for code that is
|
|
135
|
+
* not part of the article it runs on: the site chrome, the shared component packages.
|
|
136
|
+
*
|
|
137
|
+
* On a page whose template renders the project (`af.i18n[project]` exists) the strings are
|
|
138
|
+
* looked up and harvested there; on any other page they resolve through the article's
|
|
139
|
+
* dictionary as a plain `getLocalizedString` would.
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```ts
|
|
143
|
+
* const { getLocalizedString: t } = createLocalizer("synergi-chrome");
|
|
144
|
+
* t("Applications");
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export function createLocalizer(project: string) {
|
|
148
|
+
function bound(string: undefined | null): undefined | null;
|
|
149
|
+
function bound(string: string): string;
|
|
150
|
+
function bound(string: string, wait: true): Promise<string>;
|
|
151
|
+
function bound(string: string | undefined | null, wait?: boolean): string | undefined | null | Promise<string> {
|
|
152
|
+
return getLocalizedString(string as string, { project, wait } as LocalizeOptions & { wait: true });
|
|
62
153
|
}
|
|
154
|
+
|
|
155
|
+
function localize(stringParts: TemplateStringsArray, ...args: unknown[]): string {
|
|
156
|
+
let stringToLocalize = stringParts.map((s, i) => (i === stringParts.length - 1 ? s : `${s}$${i}`)).join("");
|
|
157
|
+
let localized = bound(stringToLocalize);
|
|
158
|
+
|
|
159
|
+
args.forEach((arg, index) => {
|
|
160
|
+
localized = localized.replace(`$${index}`, String(arg));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return localized;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { project, getLocalizedString: bound, localize };
|
|
63
167
|
}
|
package/src/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type { DownloadItem, DownloadParameters } from "./downloadRequest.js";
|
|
|
14
14
|
export { downloadRequest } from "./downloadRequest.js";
|
|
15
15
|
export { expose } from "./expose.js";
|
|
16
16
|
export * from "./filter/index.js";
|
|
17
|
-
export { getLocalizedString } from "./getLocalizedString.js";
|
|
17
|
+
export { createLocalizer, getLocalizedString, type LocalizeOptions } from "./getLocalizedString.js";
|
|
18
18
|
export type { getUserImageOptions } from "./getUserImage.js";
|
|
19
19
|
export { getUserImage } from "./getUserImage.js";
|
|
20
20
|
export { invariant } from "./invariant.js";
|
|
@@ -50,6 +50,12 @@ export interface AfArticle {
|
|
|
50
50
|
|
|
51
51
|
export interface AfCore {
|
|
52
52
|
article: AfArticle;
|
|
53
|
+
/**
|
|
54
|
+
* Strings of globalization projects other than the article's, by project name, for
|
|
55
|
+
* code that runs on every page without belonging to an article (the site chrome).
|
|
56
|
+
* Rendered server side or filled by the caller; see `getLocalizedString`'s `project`.
|
|
57
|
+
*/
|
|
58
|
+
i18n?: Record<string, Record<string, string>>;
|
|
53
59
|
common: {
|
|
54
60
|
getLocalizedString: typeof getLocalizedString;
|
|
55
61
|
expose: typeof expose;
|