@quark-fw/plugin-react 0.1.6 → 0.1.8
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/package.json +12 -10
- package/src/Router/PageComponentRenderer.tsx +21 -0
- package/src/Router/Router.tsx +5 -178
- package/src/Router/UglyModule.test.ts +48 -0
- package/src/Router/UglyModule.ts +98 -0
- package/src/Router/UglyModuleRenderer.tsx +69 -0
- package/src/Router/UglyRouter.tsx +77 -0
- package/src/Router/createUglyModules.ts +32 -0
- package/src/Router/gatherPages.ts +7 -149
- package/src/Router/types.ts +30 -0
- package/src/build.ts +1 -1
- package/src/frontend/App.tsx +6 -4
- package/src/frontend/entry-client.tsx +4 -2
- package/src/frontend/entry-server.tsx +1 -1
- package/src/i18n/I18nContext.tsx +1 -191
- package/src/i18n/I18nProvider.tsx +168 -0
- package/src/i18n/helpers.ts +29 -0
- package/src/seo/SeoContext.tsx +2 -78
- package/src/seo/SeoProvider.tsx +72 -0
- package/src/ssr/getRenderMiddleware.ts +7 -3
- package/src/utils/createViteConfig.ts +81 -0
- package/src/utils/vite.ts +1 -1
- package/src/utils/viteConfig.ts +2 -76
- package/src/utils/usePromise.ts +0 -24
|
@@ -1,34 +1,12 @@
|
|
|
1
1
|
/// <reference types="vite/client" />
|
|
2
|
-
|
|
3
|
-
import {
|
|
2
|
+
// Синглтоны загруженных страниц/лейаутов приложения (glob по /Pages).
|
|
3
|
+
import { createUglyModules } from "./createUglyModules.js";
|
|
4
|
+
import type { TPageModule, ViteModules } from "./types.js";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* страницу безусловно.
|
|
10
|
-
*/
|
|
11
|
-
/* eslint-disable react-hooks/rules-of-hooks */
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Модуль страницы. Пропсы страниц описывает роутер (UglyPageProps), но
|
|
15
|
-
* загрузчик их не разбирает — ему важно лишь, что это компонент.
|
|
16
|
-
*/
|
|
17
|
-
export type TPageModule = {
|
|
18
|
-
default: React.ComponentType<Record<string, unknown>>;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
type TUrlPath = string;
|
|
22
|
-
type TModulePath = string;
|
|
23
|
-
type TFilePath = string;
|
|
24
|
-
|
|
25
|
-
export type ViteModules = {
|
|
26
|
-
[key: TModulePath]: () => Promise<TPageModule>;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
export type ViteModulesEager = {
|
|
30
|
-
[key: TModulePath]: TPageModule;
|
|
31
|
-
};
|
|
6
|
+
// реэкспорт для прежних импортёров этого модуля
|
|
7
|
+
export { UglyModule } from "./UglyModule.js";
|
|
8
|
+
export { createUglyModules } from "./createUglyModules.js";
|
|
9
|
+
export type { TPageModule, ViteModules, ViteModulesEager } from "./types.js";
|
|
32
10
|
|
|
33
11
|
const pages: ViteModules = import.meta.glob<TPageModule>("/Pages/**/Page.*");
|
|
34
12
|
const layouts: ViteModules = import.meta.glob<TPageModule>(
|
|
@@ -38,126 +16,6 @@ const resetLayouts: ViteModules = import.meta.glob<TPageModule>(
|
|
|
38
16
|
"/Pages/**/LayoutReset.*",
|
|
39
17
|
);
|
|
40
18
|
|
|
41
|
-
const getMeta = (modulePath: TModulePath): [TFilePath, TUrlPath] => {
|
|
42
|
-
const filePath = modulePath.replace(/^\/Pages/, "").replace(/(\.tsx)$/, "");
|
|
43
|
-
let urlPath = (
|
|
44
|
-
filePath.endsWith("/Page")
|
|
45
|
-
? filePath.replace(/Page$/, "")
|
|
46
|
-
: `${filePath}/`
|
|
47
|
-
)
|
|
48
|
-
.replace(/\[\.{3}.+\]/, "*")
|
|
49
|
-
.replaceAll(/\[(.[^[]+)\]/g, ":$1")
|
|
50
|
-
.toLowerCase();
|
|
51
|
-
|
|
52
|
-
urlPath = urlPath === "/main/" ? "/" : urlPath;
|
|
53
|
-
|
|
54
|
-
return [filePath, urlPath];
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
export class UglyModule {
|
|
58
|
-
modulePath: string;
|
|
59
|
-
|
|
60
|
-
moduleImport: () => Promise<TPageModule>;
|
|
61
|
-
|
|
62
|
-
module?: TPageModule;
|
|
63
|
-
|
|
64
|
-
loadingPromise?: Promise<TPageModule>;
|
|
65
|
-
|
|
66
|
-
wrappedPromise?: () => TPageModule;
|
|
67
|
-
|
|
68
|
-
filePath: string;
|
|
69
|
-
|
|
70
|
-
urlPath: string;
|
|
71
|
-
|
|
72
|
-
constructor({
|
|
73
|
-
moduleImport,
|
|
74
|
-
modulePath,
|
|
75
|
-
module,
|
|
76
|
-
}: {
|
|
77
|
-
moduleImport: () => Promise<TPageModule>;
|
|
78
|
-
modulePath: TModulePath;
|
|
79
|
-
module?: TPageModule;
|
|
80
|
-
}) {
|
|
81
|
-
this.modulePath = modulePath;
|
|
82
|
-
this.moduleImport = moduleImport;
|
|
83
|
-
this.module = module;
|
|
84
|
-
// this.loadingPromise;
|
|
85
|
-
const [filePath, urlPath] = getMeta(modulePath);
|
|
86
|
-
this.filePath = filePath;
|
|
87
|
-
this.urlPath = urlPath;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
getWrappedPromise(force?: TPageModule) {
|
|
91
|
-
if (!this.module && !force) {
|
|
92
|
-
if (!this.wrappedPromise) {
|
|
93
|
-
if (!this.loadingPromise) {
|
|
94
|
-
this.loadingPromise = new Promise((resolve) => {
|
|
95
|
-
try {
|
|
96
|
-
this.moduleImport().then((module) =>
|
|
97
|
-
resolve(module),
|
|
98
|
-
);
|
|
99
|
-
} catch (e) {
|
|
100
|
-
console.error(e);
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
this.wrappedPromise = usePromise(this.loadingPromise);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return this.wrappedPromise();
|
|
108
|
-
}
|
|
109
|
-
this.wrappedPromise = usePromise(
|
|
110
|
-
new Promise((resolve) => {
|
|
111
|
-
resolve(this.module! || force);
|
|
112
|
-
}),
|
|
113
|
-
);
|
|
114
|
-
return this.module;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async load() {
|
|
118
|
-
if (!this.module) {
|
|
119
|
-
if (!this.loadingPromise) {
|
|
120
|
-
this.loadingPromise = new Promise((resolve) => {
|
|
121
|
-
try {
|
|
122
|
-
this.moduleImport().then((module) => resolve(module));
|
|
123
|
-
} catch (e) {
|
|
124
|
-
console.error(e);
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
this.module = await this.loadingPromise;
|
|
131
|
-
|
|
132
|
-
return this.module;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export const createUglyModules = (modules: ViteModules | ViteModulesEager) =>
|
|
137
|
-
Object.keys(modules).reduce(
|
|
138
|
-
(acc, modulePath) => {
|
|
139
|
-
const moduleImport = modules[modulePath];
|
|
140
|
-
if (typeof moduleImport === "object") {
|
|
141
|
-
acc[modulePath] = new UglyModule({
|
|
142
|
-
modulePath,
|
|
143
|
-
module: moduleImport,
|
|
144
|
-
moduleImport: () =>
|
|
145
|
-
new Promise<TPageModule>((resolve) =>
|
|
146
|
-
resolve(moduleImport),
|
|
147
|
-
),
|
|
148
|
-
});
|
|
149
|
-
} else {
|
|
150
|
-
acc[modulePath] = new UglyModule({
|
|
151
|
-
modulePath,
|
|
152
|
-
moduleImport: moduleImport!,
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return acc;
|
|
157
|
-
},
|
|
158
|
-
{} as Record<TModulePath, UglyModule>,
|
|
159
|
-
);
|
|
160
|
-
|
|
161
19
|
const uglyPages = createUglyModules(pages);
|
|
162
20
|
const uglyLayouts = createUglyModules(layouts);
|
|
163
21
|
const uglyResetLayouts = createUglyModules(resetLayouts);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Типы, общие для файлов роутера (страницы, загрузчик модулей, пропсы).
|
|
2
|
+
import type { ComponentType, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Модуль страницы. Пропсы страниц описывает роутер (UglyPageProps), но
|
|
6
|
+
* загрузчик их не разбирает — ему важно лишь, что это компонент.
|
|
7
|
+
*/
|
|
8
|
+
export type TPageModule = {
|
|
9
|
+
default: ComponentType<Record<string, unknown>>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type TModulePath = string;
|
|
13
|
+
|
|
14
|
+
export type ViteModules = {
|
|
15
|
+
[key: TModulePath]: () => Promise<TPageModule>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ViteModulesEager = {
|
|
19
|
+
[key: TModulePath]: TPageModule;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type UglyPageProps = {
|
|
23
|
+
pathname: string;
|
|
24
|
+
params: Record<string, string>;
|
|
25
|
+
breadCrumbs: ReactNode | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type UglyLayoutProps = UglyPageProps & {
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
};
|
package/src/build.ts
CHANGED
|
@@ -5,9 +5,9 @@ import {
|
|
|
5
5
|
appFrontendRoot,
|
|
6
6
|
buildPaths,
|
|
7
7
|
clientEntry,
|
|
8
|
-
createViteConfig,
|
|
9
8
|
serverEntry,
|
|
10
9
|
} from "./utils/viteConfig.js";
|
|
10
|
+
import { createViteConfig } from "./utils/createViteConfig.js";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Прод-сборка приложения: клиентский бандл (с manifest) и SSR-бандл.
|
package/src/frontend/App.tsx
CHANGED
|
@@ -10,9 +10,10 @@ import { ModalsProvider } from "@mantine/modals";
|
|
|
10
10
|
import { Notifications } from "@mantine/notifications";
|
|
11
11
|
import { MantineProvider } from "@mantine/core";
|
|
12
12
|
|
|
13
|
-
import { UglyModule
|
|
13
|
+
import { UglyModule } from "../Router/UglyModule.js";
|
|
14
|
+
import type { ViteModulesEager } from "../Router/types.js";
|
|
14
15
|
import { ErrorHandler } from "./ErrorHandler.js";
|
|
15
|
-
import { UglyRouter } from "../Router/
|
|
16
|
+
import { UglyRouter } from "../Router/UglyRouter.js";
|
|
16
17
|
import Store, {
|
|
17
18
|
StoreContext,
|
|
18
19
|
TRPCContext,
|
|
@@ -20,8 +21,9 @@ import Store, {
|
|
|
20
21
|
} from "@quark-fw/store";
|
|
21
22
|
// регистрирует показ ошибок через notifications (side-effect импорт)
|
|
22
23
|
import "./displayError.js";
|
|
23
|
-
import { SeoProvider
|
|
24
|
-
import {
|
|
24
|
+
import { SeoProvider } from "../seo/SeoProvider.js";
|
|
25
|
+
import type { SeoMap } from "../seo/SeoContext.js";
|
|
26
|
+
import { I18nProvider, type EagerLocaleModules } from "../i18n/I18nProvider.js";
|
|
25
27
|
import type { I18nConfig } from "../i18n/config.js";
|
|
26
28
|
// @ts-expect-error virtual file
|
|
27
29
|
import { appTheme } from "/theme.ts";
|
|
@@ -4,13 +4,14 @@ import { startTransition } from "react";
|
|
|
4
4
|
import Store, { trpcClient } from "@quark-fw/store";
|
|
5
5
|
import { getRenderPath } from "../Router/getRenderPath.js";
|
|
6
6
|
import { parseLocalePath, type I18nConfig } from "../i18n/config.js";
|
|
7
|
+
import type { SeoMap } from "../seo/SeoContext.js";
|
|
7
8
|
import App from "./App.js";
|
|
8
9
|
|
|
9
10
|
/** то, что SSR кладёт в window для гидратации */
|
|
10
11
|
type HydrationWindow = typeof window & {
|
|
11
12
|
cache?: Record<string, unknown>;
|
|
12
13
|
store?: Store;
|
|
13
|
-
seo?:
|
|
14
|
+
seo?: SeoMap;
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
const hydrationWindow = window as HydrationWindow;
|
|
@@ -31,7 +32,8 @@ if (rootEl) {
|
|
|
31
32
|
);
|
|
32
33
|
const { path } = getRenderPath(strippedPathname);
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
// прогрев чанков; ошибка не фатальна — рендер сам продолжит загрузку
|
|
36
|
+
void Promise.all(path.map((e) => e.load())).catch(console.error);
|
|
35
37
|
|
|
36
38
|
const parsedProps = Object.keys(rootEl.dataset).reduce(
|
|
37
39
|
(acc, key) => {
|
|
@@ -16,7 +16,7 @@ import HTML from "./HTML/index.js";
|
|
|
16
16
|
import App from "./App.js";
|
|
17
17
|
import type { ResolvedSeo } from "../seo/createPageMeta.js";
|
|
18
18
|
import type { I18nConfig } from "../i18n/config.js";
|
|
19
|
-
import type { EagerLocaleModules } from "../i18n/
|
|
19
|
+
import type { EagerLocaleModules } from "../i18n/I18nProvider.js";
|
|
20
20
|
|
|
21
21
|
// i18n опционален: если у приложения нет /i18n.ts — glob вернёт пустой объект
|
|
22
22
|
const i18nModules: Record<string, { i18nConfig?: I18nConfig }> =
|
package/src/i18n/I18nContext.tsx
CHANGED
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { createContext, ReactNode, useContext, useMemo } from "react";
|
|
3
|
-
import { useStore } from "@quark-fw/store";
|
|
4
|
-
import { usePromise } from "@quark-fw/store/src/usePromise.js";
|
|
5
|
-
import {
|
|
6
|
-
buildLocalePath,
|
|
7
|
-
usesLocalePrefix,
|
|
8
|
-
type I18nConfig,
|
|
9
|
-
} from "./config.js";
|
|
10
|
-
import { getLocaleImporter, type LocaleModule } from "./gatherLocales.js";
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
11
2
|
|
|
12
3
|
export type Translate = (
|
|
13
4
|
key: string,
|
|
@@ -33,185 +24,4 @@ const I18nContext = createContext<I18nContextValue>({
|
|
|
33
24
|
|
|
34
25
|
export const useI18n = () => useContext(I18nContext);
|
|
35
26
|
|
|
36
|
-
export type EagerLocaleModules = Record<string, LocaleModule>;
|
|
37
|
-
|
|
38
|
-
const deepMerge = (base: unknown, override: unknown): unknown => {
|
|
39
|
-
if (
|
|
40
|
-
typeof base !== "object" ||
|
|
41
|
-
base === null ||
|
|
42
|
-
typeof override !== "object" ||
|
|
43
|
-
override === null
|
|
44
|
-
) {
|
|
45
|
-
return typeof override === "undefined" ? base : override;
|
|
46
|
-
}
|
|
47
|
-
const source = base as Record<string, unknown>;
|
|
48
|
-
const patch = override as Record<string, unknown>;
|
|
49
|
-
const out: Record<string, unknown> = { ...source };
|
|
50
|
-
for (const key of Object.keys(patch)) {
|
|
51
|
-
out[key] = deepMerge(source[key], patch[key]);
|
|
52
|
-
}
|
|
53
|
-
return out;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const interpolate = (
|
|
57
|
-
template: string,
|
|
58
|
-
params?: Record<string, string | number>,
|
|
59
|
-
) =>
|
|
60
|
-
params
|
|
61
|
-
? template.replace(/\{(\w+)\}/g, (match, name) =>
|
|
62
|
-
name in params ? String(params[name]) : match,
|
|
63
|
-
)
|
|
64
|
-
: template;
|
|
65
|
-
|
|
66
|
-
export function I18nProvider({
|
|
67
|
-
locale,
|
|
68
|
-
config,
|
|
69
|
-
eagerLocaleModules,
|
|
70
|
-
children,
|
|
71
|
-
}: {
|
|
72
|
-
locale: string;
|
|
73
|
-
config: I18nConfig;
|
|
74
|
-
/** передаётся только на сервере (eager-glob из entry-server) */
|
|
75
|
-
eagerLocaleModules?: EagerLocaleModules;
|
|
76
|
-
children: ReactNode;
|
|
77
|
-
}) {
|
|
78
|
-
const store = useStore();
|
|
79
|
-
|
|
80
|
-
const value: I18nContextValue = useMemo(() => {
|
|
81
|
-
const defaultLocale = config?.defaultLocale || locale;
|
|
82
|
-
let pluralRules: Intl.PluralRules;
|
|
83
|
-
try {
|
|
84
|
-
pluralRules = new Intl.PluralRules(locale || undefined);
|
|
85
|
-
} catch {
|
|
86
|
-
pluralRules = new Intl.PluralRules();
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/*
|
|
90
|
-
* usePromise ниже — хук, вызванный не из компонента: словарь читается в
|
|
91
|
-
* теле рендера потребителя ($), поэтому порядок вызовов совпадает с
|
|
92
|
-
* порядком чтений. Кэш стора при этом заполняется намеренно — это его
|
|
93
|
-
* назначение, а не мутация чужого состояния.
|
|
94
|
-
*/
|
|
95
|
-
/* eslint-disable react-hooks/rules-of-hooks, react-hooks/immutability */
|
|
96
|
-
const loadNamespace = (ns: string, cacheKey: string): unknown => {
|
|
97
|
-
if (eagerLocaleModules) {
|
|
98
|
-
// SSR: синхронно склеиваем словари и стримим их клиенту
|
|
99
|
-
const def =
|
|
100
|
-
eagerLocaleModules[`/locales/${defaultLocale}/${ns}.ts`]
|
|
101
|
-
?.default;
|
|
102
|
-
const cur =
|
|
103
|
-
locale === defaultLocale
|
|
104
|
-
? undefined
|
|
105
|
-
: eagerLocaleModules[`/locales/${locale}/${ns}.ts`]
|
|
106
|
-
?.default;
|
|
107
|
-
const data = deepMerge(def ?? {}, cur ?? {});
|
|
108
|
-
store.values[cacheKey] = data;
|
|
109
|
-
store.callback?.(cacheKey, data);
|
|
110
|
-
return data;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// клиент: подвешиваемся на ленивые чанки vite
|
|
114
|
-
const { reactPromises } = store;
|
|
115
|
-
if (!reactPromises[cacheKey]) {
|
|
116
|
-
const importDefault = getLocaleImporter(defaultLocale, ns);
|
|
117
|
-
const importCurrent =
|
|
118
|
-
locale === defaultLocale
|
|
119
|
-
? undefined
|
|
120
|
-
: getLocaleImporter(locale, ns);
|
|
121
|
-
if (!importDefault && !importCurrent) {
|
|
122
|
-
store.values[cacheKey] = {};
|
|
123
|
-
return store.values[cacheKey];
|
|
124
|
-
}
|
|
125
|
-
reactPromises[cacheKey] = usePromise(
|
|
126
|
-
Promise.all([importDefault?.(), importCurrent?.()]).then(
|
|
127
|
-
([def, cur]) => {
|
|
128
|
-
const merged = deepMerge(
|
|
129
|
-
def?.default ?? {},
|
|
130
|
-
cur?.default ?? {},
|
|
131
|
-
);
|
|
132
|
-
store.values[cacheKey] = merged;
|
|
133
|
-
return merged;
|
|
134
|
-
},
|
|
135
|
-
),
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
return reactPromises[cacheKey]();
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
const $: Translate = (key, params) => {
|
|
142
|
-
const dot = key.indexOf(".");
|
|
143
|
-
const ns = dot === -1 ? key : key.slice(0, dot);
|
|
144
|
-
const rest = dot === -1 ? "" : key.slice(dot + 1);
|
|
145
|
-
const cacheKey = `i18n:${locale}:${ns}`;
|
|
146
|
-
|
|
147
|
-
let data = store.values[cacheKey];
|
|
148
|
-
if (typeof data === "undefined") data = loadNamespace(ns, cacheKey);
|
|
149
|
-
|
|
150
|
-
// по словарю идём как по дереву неизвестной формы
|
|
151
|
-
const asDict = (value: unknown) =>
|
|
152
|
-
value && typeof value === "object"
|
|
153
|
-
? (value as Record<string, unknown>)
|
|
154
|
-
: undefined;
|
|
155
|
-
|
|
156
|
-
let node: unknown = data;
|
|
157
|
-
for (const part of rest ? rest.split(".") : []) {
|
|
158
|
-
node = asDict(node)?.[part];
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (typeof node === "string") return interpolate(node, params);
|
|
162
|
-
|
|
163
|
-
// плюрализация: { one: "…", few: "…", other: "…" } + params.count
|
|
164
|
-
const forms = asDict(node);
|
|
165
|
-
if (forms && typeof forms.other === "string") {
|
|
166
|
-
const count = Number(params?.count);
|
|
167
|
-
const category = Number.isFinite(count)
|
|
168
|
-
? pluralRules.select(count)
|
|
169
|
-
: "other";
|
|
170
|
-
const form = forms[category];
|
|
171
|
-
return interpolate(
|
|
172
|
-
typeof form === "string" ? form : forms.other,
|
|
173
|
-
params,
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return key;
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
const setLocale = (next: string) => {
|
|
181
|
-
if (
|
|
182
|
-
!config?.locales.includes(next) ||
|
|
183
|
-
typeof window === "undefined"
|
|
184
|
-
)
|
|
185
|
-
return;
|
|
186
|
-
document.cookie = `locale=${next};path=/;max-age=31536000`;
|
|
187
|
-
if (!usesLocalePrefix(config)) {
|
|
188
|
-
window.location.reload();
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
const { pathname, search, hash } = window.location;
|
|
192
|
-
const stripped = pathname.startsWith(`/${locale}`)
|
|
193
|
-
? pathname.slice(locale.length + 1) || "/"
|
|
194
|
-
: pathname;
|
|
195
|
-
window.location.href =
|
|
196
|
-
buildLocalePath(next, stripped) + search + hash;
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
return {
|
|
200
|
-
locale,
|
|
201
|
-
locales: config?.locales ?? [],
|
|
202
|
-
defaultLocale,
|
|
203
|
-
$,
|
|
204
|
-
setLocale,
|
|
205
|
-
};
|
|
206
|
-
// config и eagerLocaleModules приходят из модулей приложения (/i18n.ts
|
|
207
|
-
// и glob по /locales) и за время жизни страницы не меняются —
|
|
208
|
-
// пересобирать контекст на их идентичность незачем
|
|
209
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
210
|
-
}, [locale, store]);
|
|
211
|
-
|
|
212
|
-
return (
|
|
213
|
-
<I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
27
|
export default I18nContext;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
import { ReactNode, useMemo } from "react";
|
|
3
|
+
import { useStore } from "@quark-fw/store";
|
|
4
|
+
import { usePromise } from "@quark-fw/store/src/usePromise.js";
|
|
5
|
+
import {
|
|
6
|
+
buildLocalePath,
|
|
7
|
+
usesLocalePrefix,
|
|
8
|
+
type I18nConfig,
|
|
9
|
+
} from "./config.js";
|
|
10
|
+
import { getLocaleImporter, type LocaleModule } from "./gatherLocales.js";
|
|
11
|
+
import { deepMerge, interpolate } from "./helpers.js";
|
|
12
|
+
import I18nContext, {
|
|
13
|
+
type I18nContextValue,
|
|
14
|
+
type Translate,
|
|
15
|
+
} from "./I18nContext.js";
|
|
16
|
+
|
|
17
|
+
export type EagerLocaleModules = Record<string, LocaleModule>;
|
|
18
|
+
|
|
19
|
+
export function I18nProvider({
|
|
20
|
+
locale,
|
|
21
|
+
config,
|
|
22
|
+
eagerLocaleModules,
|
|
23
|
+
children,
|
|
24
|
+
}: {
|
|
25
|
+
locale: string;
|
|
26
|
+
config: I18nConfig;
|
|
27
|
+
/** передаётся только на сервере (eager-glob из entry-server) */
|
|
28
|
+
eagerLocaleModules?: EagerLocaleModules;
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
}) {
|
|
31
|
+
const store = useStore();
|
|
32
|
+
|
|
33
|
+
const value: I18nContextValue = useMemo(() => {
|
|
34
|
+
const defaultLocale = config?.defaultLocale || locale;
|
|
35
|
+
let pluralRules: Intl.PluralRules;
|
|
36
|
+
try {
|
|
37
|
+
pluralRules = new Intl.PluralRules(locale || undefined);
|
|
38
|
+
} catch {
|
|
39
|
+
pluralRules = new Intl.PluralRules();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
* usePromise ниже — хук, вызванный не из компонента: словарь читается в
|
|
44
|
+
* теле рендера потребителя ($), поэтому порядок вызовов совпадает с
|
|
45
|
+
* порядком чтений. Кэш стора при этом заполняется намеренно — это его
|
|
46
|
+
* назначение, а не мутация чужого состояния.
|
|
47
|
+
*/
|
|
48
|
+
/* eslint-disable react-hooks/rules-of-hooks, react-hooks/immutability */
|
|
49
|
+
const loadNamespace = (ns: string, cacheKey: string): unknown => {
|
|
50
|
+
if (eagerLocaleModules) {
|
|
51
|
+
// SSR: синхронно склеиваем словари и стримим их клиенту
|
|
52
|
+
const def =
|
|
53
|
+
eagerLocaleModules[`/locales/${defaultLocale}/${ns}.ts`]
|
|
54
|
+
?.default;
|
|
55
|
+
const cur =
|
|
56
|
+
locale === defaultLocale
|
|
57
|
+
? undefined
|
|
58
|
+
: eagerLocaleModules[`/locales/${locale}/${ns}.ts`]
|
|
59
|
+
?.default;
|
|
60
|
+
const data = deepMerge(def ?? {}, cur ?? {});
|
|
61
|
+
store.values[cacheKey] = data;
|
|
62
|
+
store.callback?.(cacheKey, data);
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// клиент: подвешиваемся на ленивые чанки vite
|
|
67
|
+
const { reactPromises } = store;
|
|
68
|
+
if (!reactPromises[cacheKey]) {
|
|
69
|
+
const importDefault = getLocaleImporter(defaultLocale, ns);
|
|
70
|
+
const importCurrent =
|
|
71
|
+
locale === defaultLocale
|
|
72
|
+
? undefined
|
|
73
|
+
: getLocaleImporter(locale, ns);
|
|
74
|
+
if (!importDefault && !importCurrent) {
|
|
75
|
+
store.values[cacheKey] = {};
|
|
76
|
+
return store.values[cacheKey];
|
|
77
|
+
}
|
|
78
|
+
reactPromises[cacheKey] = usePromise(
|
|
79
|
+
Promise.all([importDefault?.(), importCurrent?.()]).then(
|
|
80
|
+
([def, cur]) => {
|
|
81
|
+
const merged = deepMerge(
|
|
82
|
+
def?.default ?? {},
|
|
83
|
+
cur?.default ?? {},
|
|
84
|
+
);
|
|
85
|
+
store.values[cacheKey] = merged;
|
|
86
|
+
return merged;
|
|
87
|
+
},
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return reactPromises[cacheKey]();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const $: Translate = (key, params) => {
|
|
95
|
+
const dot = key.indexOf(".");
|
|
96
|
+
const ns = dot === -1 ? key : key.slice(0, dot);
|
|
97
|
+
const rest = dot === -1 ? "" : key.slice(dot + 1);
|
|
98
|
+
const cacheKey = `i18n:${locale}:${ns}`;
|
|
99
|
+
|
|
100
|
+
let data = store.values[cacheKey];
|
|
101
|
+
if (typeof data === "undefined") data = loadNamespace(ns, cacheKey);
|
|
102
|
+
|
|
103
|
+
// по словарю идём как по дереву неизвестной формы
|
|
104
|
+
const asDict = (value: unknown) =>
|
|
105
|
+
value && typeof value === "object"
|
|
106
|
+
? (value as Record<string, unknown>)
|
|
107
|
+
: undefined;
|
|
108
|
+
|
|
109
|
+
let node: unknown = data;
|
|
110
|
+
for (const part of rest ? rest.split(".") : []) {
|
|
111
|
+
node = asDict(node)?.[part];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (typeof node === "string") return interpolate(node, params);
|
|
115
|
+
|
|
116
|
+
// плюрализация: { one: "…", few: "…", other: "…" } + params.count
|
|
117
|
+
const forms = asDict(node);
|
|
118
|
+
if (forms && typeof forms.other === "string") {
|
|
119
|
+
const count = Number(params?.count);
|
|
120
|
+
const category = Number.isFinite(count)
|
|
121
|
+
? pluralRules.select(count)
|
|
122
|
+
: "other";
|
|
123
|
+
const form = forms[category];
|
|
124
|
+
return interpolate(
|
|
125
|
+
typeof form === "string" ? form : forms.other,
|
|
126
|
+
params,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return key;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const setLocale = (next: string) => {
|
|
134
|
+
if (
|
|
135
|
+
!config?.locales.includes(next) ||
|
|
136
|
+
typeof window === "undefined"
|
|
137
|
+
)
|
|
138
|
+
return;
|
|
139
|
+
document.cookie = `locale=${next};path=/;max-age=31536000`;
|
|
140
|
+
if (!usesLocalePrefix(config)) {
|
|
141
|
+
window.location.reload();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const { pathname, search, hash } = window.location;
|
|
145
|
+
const stripped = pathname.startsWith(`/${locale}`)
|
|
146
|
+
? pathname.slice(locale.length + 1) || "/"
|
|
147
|
+
: pathname;
|
|
148
|
+
window.location.href =
|
|
149
|
+
buildLocalePath(next, stripped) + search + hash;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
locale,
|
|
154
|
+
locales: config?.locales ?? [],
|
|
155
|
+
defaultLocale,
|
|
156
|
+
$,
|
|
157
|
+
setLocale,
|
|
158
|
+
};
|
|
159
|
+
// config и eagerLocaleModules приходят из модулей приложения (/i18n.ts
|
|
160
|
+
// и glob по /locales) и за время жизни страницы не меняются —
|
|
161
|
+
// пересобирать контекст на их идентичность незачем
|
|
162
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
163
|
+
}, [locale, store]);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Хелперы i18n: слияние словарей и подстановка параметров.
|
|
2
|
+
|
|
3
|
+
export const deepMerge = (base: unknown, override: unknown): unknown => {
|
|
4
|
+
if (
|
|
5
|
+
typeof base !== "object" ||
|
|
6
|
+
base === null ||
|
|
7
|
+
typeof override !== "object" ||
|
|
8
|
+
override === null
|
|
9
|
+
) {
|
|
10
|
+
return typeof override === "undefined" ? base : override;
|
|
11
|
+
}
|
|
12
|
+
const source = base as Record<string, unknown>;
|
|
13
|
+
const patch = override as Record<string, unknown>;
|
|
14
|
+
const out: Record<string, unknown> = { ...source };
|
|
15
|
+
for (const key of Object.keys(patch)) {
|
|
16
|
+
out[key] = deepMerge(source[key], patch[key]);
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const interpolate = (
|
|
22
|
+
template: string,
|
|
23
|
+
params?: Record<string, string | number>,
|
|
24
|
+
) =>
|
|
25
|
+
params
|
|
26
|
+
? template.replace(/\{(\w+)\}/g, (match, name) =>
|
|
27
|
+
name in params ? String(params[name]) : match,
|
|
28
|
+
)
|
|
29
|
+
: template;
|