@olenbetong/appframe-core 2.13.1 → 2.15.1

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.
@@ -1,6 +1,42 @@
1
- async function fetchLocalizedString(text: string) {
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/${af.article.id}`, {
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
- wait?: boolean,
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
- if (globalThis.af?.article?.i18n !== undefined) {
46
- if (!Object.hasOwn(af.article.i18n, string)) {
47
- af.article.i18n[string] = string;
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
- let promise = fetchLocalizedString(string).then((text) => {
50
- af.article.i18n[string] = text;
51
- return text;
52
- });
116
+ let { dictionary, harvestAs } = source;
117
+ if (!Object.hasOwn(dictionary, string)) {
118
+ dictionary[string] = string;
53
119
 
54
- if (wait) {
55
- return promise;
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
- return af.article.i18n[string];
60
- } else {
61
- return string;
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;
@@ -5,6 +5,7 @@ import {
5
5
  getReportExtension,
6
6
  getReportFileName,
7
7
  getReportRequest,
8
+ openReport,
8
9
  printReport,
9
10
  type ReportRenderOptions,
10
11
  renderReport,
@@ -145,7 +146,41 @@ describe("printReport", () => {
145
146
 
146
147
  await printReport(options());
147
148
 
148
- expect(openMock).toHaveBeenCalledWith(expect.stringContaining("blob:"), "_blank", "noopener");
149
+ expect(openMock).toHaveBeenCalledWith(expect.stringContaining("blob:"), "_blank");
150
+ });
151
+ });
152
+
153
+ describe("openReport", () => {
154
+ afterEach(() => {
155
+ vi.restoreAllMocks();
156
+ });
157
+
158
+ function stubRender() {
159
+ vi.stubGlobal(
160
+ "fetch",
161
+ vi.fn().mockResolvedValue(new Response("%PDF-", { headers: { "Content-Type": "application/pdf" } })),
162
+ );
163
+ }
164
+
165
+ it("keeps the file available to the tab it opened, and cuts the tab off from the opener", async () => {
166
+ let tab = { opener: {} as unknown };
167
+ let revoke = vi.spyOn(URL, "revokeObjectURL");
168
+ stubRender();
169
+ vi.stubGlobal("open", vi.fn().mockReturnValue(tab));
170
+
171
+ await openReport(options());
172
+
173
+ expect(revoke).not.toHaveBeenCalled();
174
+ expect(tab.opener).toBeNull();
175
+ });
176
+
177
+ it("reports a blocked pop-up and releases the file", async () => {
178
+ let revoke = vi.spyOn(URL, "revokeObjectURL");
179
+ stubRender();
180
+ vi.stubGlobal("open", vi.fn().mockReturnValue(null));
181
+
182
+ await expect(openReport(options())).rejects.toThrow("Allow pop-ups");
183
+ expect(revoke).toHaveBeenCalled();
149
184
  });
150
185
  });
151
186
 
@@ -193,13 +193,18 @@ export async function openReport(options: ReportRenderOptions): Promise<void> {
193
193
  // The route serves some formats as types a browser downloads rather than
194
194
  // displays; the format's own media type is what decides how it opens.
195
195
  let url = URL.createObjectURL(blob.type ? blob : new Blob([blob], { type: getReportMediaType(options.format) }));
196
- let opened = globalThis.open(url, "_blank", "noopener");
196
+ // Not opened with the `noopener` feature: `open` then returns null even when
197
+ // the tab opens, which read as a blocked pop-up and revoked the URL before
198
+ // the tab could load it. The opener is cut off by hand instead.
199
+ let opened = globalThis.open(url, "_blank");
197
200
 
198
201
  if (!opened) {
199
202
  URL.revokeObjectURL(url);
200
203
  throw new Error("The report could not be opened. Allow pop-ups for this site and try again.");
201
204
  }
202
205
 
206
+ opened.opener = null;
207
+
203
208
  // Revoking immediately would cancel the load in some browsers, and never
204
209
  // revoking keeps the whole rendered report in memory.
205
210
  globalThis.setTimeout(() => URL.revokeObjectURL(url), 60_000);