@quark-fw/plugin-react 0.1.2
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 +50 -0
- package/src/Router/ErrorPage.tsx +18 -0
- package/src/Router/Router.tsx +178 -0
- package/src/Router/gatherPages.ts +165 -0
- package/src/Router/getMatchedRoute.ts +24 -0
- package/src/Router/getRenderPath.ts +32 -0
- package/src/build.ts +64 -0
- package/src/frontend/App.tsx +99 -0
- package/src/frontend/ErrorHandler.tsx +58 -0
- package/src/frontend/HTML/index.tsx +155 -0
- package/src/frontend/displayError.ts +16 -0
- package/src/frontend/entry-client.tsx +74 -0
- package/src/frontend/entry-server.tsx +131 -0
- package/src/global.d.ts +19 -0
- package/src/i18n/I18nContext.tsx +217 -0
- package/src/i18n/config.ts +112 -0
- package/src/i18n/gatherLocales.ts +12 -0
- package/src/index.html +13 -0
- package/src/index.ts +102 -0
- package/src/seo/SeoContext.tsx +94 -0
- package/src/seo/createPageMeta.ts +68 -0
- package/src/ssr/getRenderMiddleware.ts +273 -0
- package/src/utils/plugins.ts +73 -0
- package/src/utils/removeSearchParamsFromUrl.ts +18 -0
- package/src/utils/serializeForScript.ts +26 -0
- package/src/utils/usePromise.ts +24 -0
- package/src/utils/vite.ts +31 -0
- package/src/utils/viteConfig.ts +71 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import { ColorSchemeScript } from "@mantine/core";
|
|
3
|
+
import { Manifest } from "vite";
|
|
4
|
+
import removeSearchParamsFromUrl from "../../utils/removeSearchParamsFromUrl.js";
|
|
5
|
+
import serializeForScript from "../../utils/serializeForScript.js";
|
|
6
|
+
import type { ResolvedSeo } from "../../seo/createPageMeta.js";
|
|
7
|
+
|
|
8
|
+
export type HTMLProps = {
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
colorScheme: "dark" | "light" | undefined;
|
|
11
|
+
manifest: Manifest;
|
|
12
|
+
pathname?: string;
|
|
13
|
+
/** путь БЕЗ префикса локали — по нему клиентский SeoProvider ищет мету */
|
|
14
|
+
seoPathname?: string;
|
|
15
|
+
seo?: ResolvedSeo;
|
|
16
|
+
/** иконка вкладки; приложение задаёт её атомом quark.react.favicon */
|
|
17
|
+
favicon?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function HTML({
|
|
21
|
+
colorScheme,
|
|
22
|
+
manifest,
|
|
23
|
+
pathname,
|
|
24
|
+
seoPathname,
|
|
25
|
+
seo,
|
|
26
|
+
favicon,
|
|
27
|
+
}: HTMLProps) {
|
|
28
|
+
const canonical = removeSearchParamsFromUrl(pathname!);
|
|
29
|
+
const og = seo?.og;
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<head>
|
|
33
|
+
<title>{seo?.title ?? "App"}</title>
|
|
34
|
+
<link rel="canonical" href={canonical} />
|
|
35
|
+
{favicon ? <link rel="icon" href={favicon} /> : null}
|
|
36
|
+
{seo?.description ? (
|
|
37
|
+
<meta name="description" content={seo.description} />
|
|
38
|
+
) : null}
|
|
39
|
+
{seo?.robots ? (
|
|
40
|
+
<meta name="robots" content={seo.robots} />
|
|
41
|
+
) : null}
|
|
42
|
+
{og || seo?.title ? (
|
|
43
|
+
<>
|
|
44
|
+
<meta
|
|
45
|
+
property="og:title"
|
|
46
|
+
content={og?.title ?? seo?.title ?? ""}
|
|
47
|
+
/>
|
|
48
|
+
<meta property="og:url" content={canonical} />
|
|
49
|
+
<meta
|
|
50
|
+
property="og:type"
|
|
51
|
+
content={og?.type ?? "website"}
|
|
52
|
+
/>
|
|
53
|
+
{(og?.description ?? seo?.description) ? (
|
|
54
|
+
<meta
|
|
55
|
+
property="og:description"
|
|
56
|
+
content={
|
|
57
|
+
og?.description ?? seo?.description ?? ""
|
|
58
|
+
}
|
|
59
|
+
/>
|
|
60
|
+
) : null}
|
|
61
|
+
{og?.image ? (
|
|
62
|
+
<meta property="og:image" content={og.image} />
|
|
63
|
+
) : null}
|
|
64
|
+
{og?.siteName ? (
|
|
65
|
+
<meta
|
|
66
|
+
property="og:site_name"
|
|
67
|
+
content={og.siteName}
|
|
68
|
+
/>
|
|
69
|
+
) : null}
|
|
70
|
+
</>
|
|
71
|
+
) : null}
|
|
72
|
+
{/* мета для клиентской навигации (SeoProvider) */}
|
|
73
|
+
<script
|
|
74
|
+
dangerouslySetInnerHTML={{
|
|
75
|
+
// ключ — путь, который увидит useLocation() на клиенте
|
|
76
|
+
// (basename роутера уже срезан), иначе SeoProvider
|
|
77
|
+
// промахнётся и перезапросит мету заново
|
|
78
|
+
__html: `window.seo=${serializeForScript({
|
|
79
|
+
[seoPathname ?? canonical]: seo ?? {},
|
|
80
|
+
})}`,
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
<ColorSchemeScript
|
|
84
|
+
defaultColorScheme={colorScheme || "light"}
|
|
85
|
+
/>
|
|
86
|
+
<meta charSet="utf-8" />
|
|
87
|
+
<meta
|
|
88
|
+
name="viewport"
|
|
89
|
+
content="width=device-width, initial-scale=1, maximum-scale=1"
|
|
90
|
+
/>
|
|
91
|
+
<script
|
|
92
|
+
dangerouslySetInnerHTML={{
|
|
93
|
+
__html: "window.cache = window.cache || {}",
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
|
|
97
|
+
{/* ключ манифеста — путь ИСХОДНИКА (…/entry-client.tsx),
|
|
98
|
+
поэтому ищем по имени без расширения */}
|
|
99
|
+
{Object.entries(manifest)
|
|
100
|
+
.find(([e]) => e.includes("entry-client"))?.[1]
|
|
101
|
+
?.css?.map((e) => (
|
|
102
|
+
<link rel="stylesheet" href={`/${e}`} key={e} />
|
|
103
|
+
))}
|
|
104
|
+
{process.env.NODE_ENV !== "production" ? (
|
|
105
|
+
<script
|
|
106
|
+
async
|
|
107
|
+
type="module"
|
|
108
|
+
dangerouslySetInnerHTML={{
|
|
109
|
+
__html: `
|
|
110
|
+
import RefreshRuntime from "/@react-refresh"
|
|
111
|
+
RefreshRuntime.injectIntoGlobalHook(window)
|
|
112
|
+
window.$RefreshReg$ = () => {}
|
|
113
|
+
window.$RefreshSig$ = () => (type) => type
|
|
114
|
+
window.__vite_plugin_react_preamble_installed__ = true
|
|
115
|
+
`,
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
) : null}
|
|
119
|
+
<script
|
|
120
|
+
dangerouslySetInnerHTML={{
|
|
121
|
+
// необходимо для коректной работы UgliestImage
|
|
122
|
+
__html: `
|
|
123
|
+
function refreshImg (id) {
|
|
124
|
+
if (id) {
|
|
125
|
+
function elementIsDrawn(element, callback) {
|
|
126
|
+
function checkIfDrawn() {
|
|
127
|
+
if (element.offsetWidth && element.offsetHeight) {
|
|
128
|
+
callback(element);
|
|
129
|
+
} else {
|
|
130
|
+
requestAnimationFrame(checkIfDrawn);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
checkIfDrawn();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
elementIsDrawn(document.querySelector(\`[id="$\{id}"]\`), (el) => {
|
|
138
|
+
const img = el.querySelector('img');
|
|
139
|
+
const width = el.offsetWidth * window.devicePixelRatio;
|
|
140
|
+
const height = el.offsetHeight * window.devicePixelRatio;
|
|
141
|
+
if (width && height) {
|
|
142
|
+
img.src = (img.getAttribute('_src') + '').replace('_w_', width).replace('_h_', height);
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
}
|
|
147
|
+
}`,
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
</head>
|
|
151
|
+
</>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export default HTML;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { notifications } from "@mantine/notifications";
|
|
2
|
+
import { errorMessage, setErrorHandler } from "@quark-fw/store/src/errors.js";
|
|
3
|
+
|
|
4
|
+
// На клиенте ошибки библиотек (например откат оптимистичного обновления)
|
|
5
|
+
// показываем всплывающим уведомлением. На сервере оставляем console.error:
|
|
6
|
+
// notifications требуют смонтированного провайдера.
|
|
7
|
+
if (typeof window !== "undefined") {
|
|
8
|
+
setErrorHandler((error, context) => {
|
|
9
|
+
console.error(context ? `[${context}]` : "[quark]", error);
|
|
10
|
+
notifications.show({
|
|
11
|
+
color: "red",
|
|
12
|
+
title: context ?? "Ошибка",
|
|
13
|
+
message: errorMessage(error),
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { hydrateRoot } from "react-dom/client";
|
|
2
|
+
import { BrowserRouter } from "react-router-dom";
|
|
3
|
+
import { startTransition } from "react";
|
|
4
|
+
import Store, { trpcClient } from "@quark-fw/store";
|
|
5
|
+
import { getRenderPath } from "../Router/getRenderPath.js";
|
|
6
|
+
import { parseLocalePath, type I18nConfig } from "../i18n/config.js";
|
|
7
|
+
import App from "./App.js";
|
|
8
|
+
|
|
9
|
+
/** то, что SSR кладёт в window для гидратации */
|
|
10
|
+
type HydrationWindow = typeof window & {
|
|
11
|
+
cache?: Record<string, unknown>;
|
|
12
|
+
store?: Store;
|
|
13
|
+
seo?: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const hydrationWindow = window as HydrationWindow;
|
|
17
|
+
hydrationWindow.cache = hydrationWindow.cache || {};
|
|
18
|
+
const store = new Store({ trpcClient, values: hydrationWindow.cache });
|
|
19
|
+
hydrationWindow.store = store;
|
|
20
|
+
|
|
21
|
+
// i18n опционален: без /i18n.ts glob вернёт пустой объект
|
|
22
|
+
const i18nModules: Record<string, { i18nConfig?: I18nConfig }> =
|
|
23
|
+
import.meta.glob("/i18n.ts", { eager: true });
|
|
24
|
+
const i18nConfig = Object.values(i18nModules)[0]?.i18nConfig;
|
|
25
|
+
|
|
26
|
+
const rootEl = document.getElementById("root");
|
|
27
|
+
if (rootEl) {
|
|
28
|
+
const { locale, strippedPathname, basename } = parseLocalePath(
|
|
29
|
+
window.location.pathname,
|
|
30
|
+
i18nConfig,
|
|
31
|
+
);
|
|
32
|
+
const { path } = getRenderPath(strippedPathname);
|
|
33
|
+
|
|
34
|
+
void Promise.all(path.map((e) => e.load()));
|
|
35
|
+
|
|
36
|
+
const parsedProps = Object.keys(rootEl.dataset).reduce(
|
|
37
|
+
(acc, key) => {
|
|
38
|
+
let parsed;
|
|
39
|
+
|
|
40
|
+
const data = rootEl.dataset[key]!;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(data);
|
|
44
|
+
} catch {
|
|
45
|
+
// не JSON — значит в атрибуте лежала обычная строка
|
|
46
|
+
parsed = data;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
...acc,
|
|
51
|
+
[key]: parsed,
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
{} as Record<string, unknown>,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
startTransition(() => {
|
|
58
|
+
hydrateRoot(
|
|
59
|
+
rootEl,
|
|
60
|
+
<BrowserRouter basename={basename}>
|
|
61
|
+
<App
|
|
62
|
+
registerModule={() => {}}
|
|
63
|
+
colorScheme={
|
|
64
|
+
parsedProps.colorScheme as "dark" | "light" | undefined
|
|
65
|
+
}
|
|
66
|
+
store={store}
|
|
67
|
+
trpcClient={trpcClient}
|
|
68
|
+
seoObject={hydrationWindow.seo ?? {}}
|
|
69
|
+
locale={locale}
|
|
70
|
+
/>
|
|
71
|
+
</BrowserRouter>,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type Store from "@quark-fw/store";
|
|
2
|
+
import type { TRPCClient } from "@quark-fw/store";
|
|
3
|
+
import ReactDOMServer, {
|
|
4
|
+
RenderToPipeableStreamOptions,
|
|
5
|
+
} from "react-dom/server";
|
|
6
|
+
import { StaticRouter } from "react-router-dom";
|
|
7
|
+
import type { Request } from "express";
|
|
8
|
+
import type { Manifest } from "vite";
|
|
9
|
+
import { getMatchedRoute } from "../Router/getMatchedRoute.js";
|
|
10
|
+
import {
|
|
11
|
+
createUglyModules,
|
|
12
|
+
UglyModule,
|
|
13
|
+
ViteModulesEager,
|
|
14
|
+
} from "../Router/gatherPages.js";
|
|
15
|
+
import HTML from "./HTML/index.js";
|
|
16
|
+
import App from "./App.js";
|
|
17
|
+
import type { ResolvedSeo } from "../seo/createPageMeta.js";
|
|
18
|
+
import type { I18nConfig } from "../i18n/config.js";
|
|
19
|
+
import type { EagerLocaleModules } from "../i18n/I18nContext.js";
|
|
20
|
+
|
|
21
|
+
// i18n опционален: если у приложения нет /i18n.ts — glob вернёт пустой объект
|
|
22
|
+
const i18nModules: Record<string, { i18nConfig?: I18nConfig }> =
|
|
23
|
+
import.meta.glob("/i18n.ts", { eager: true });
|
|
24
|
+
export const i18nConfig = Object.values(i18nModules)[0]?.i18nConfig;
|
|
25
|
+
|
|
26
|
+
const localeModules: EagerLocaleModules = import.meta.glob("/locales/*/*.ts", {
|
|
27
|
+
eager: true,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const pages: ViteModulesEager = import.meta.glob("/Pages/**/Page.*", {
|
|
31
|
+
eager: true,
|
|
32
|
+
});
|
|
33
|
+
const layouts: ViteModulesEager = import.meta.glob("/Pages/**/Layout.*", {
|
|
34
|
+
eager: true,
|
|
35
|
+
});
|
|
36
|
+
const resetLayouts: ViteModulesEager = import.meta.glob(
|
|
37
|
+
"/Pages/**/LayoutReset.*",
|
|
38
|
+
{ eager: true },
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const uglyPages = createUglyModules(pages);
|
|
42
|
+
|
|
43
|
+
export interface RendererPipeParams {
|
|
44
|
+
req: Request;
|
|
45
|
+
options: RenderToPipeableStreamOptions;
|
|
46
|
+
registerModule: (module: UglyModule) => void;
|
|
47
|
+
colorScheme?: "light" | "dark";
|
|
48
|
+
store: Store;
|
|
49
|
+
trpcClient: TRPCClient;
|
|
50
|
+
seo?: ResolvedSeo;
|
|
51
|
+
seoObject?: Record<string, ResolvedSeo>;
|
|
52
|
+
/** иконка вкладки из атома приложения */
|
|
53
|
+
favicon?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const hasUglyPage = (url: string) => getMatchedRoute(url, uglyPages);
|
|
57
|
+
|
|
58
|
+
export class Renderer {
|
|
59
|
+
url: string;
|
|
60
|
+
manifest: Manifest;
|
|
61
|
+
locale?: string;
|
|
62
|
+
basename?: string;
|
|
63
|
+
strippedPathname?: string;
|
|
64
|
+
|
|
65
|
+
constructor({
|
|
66
|
+
url,
|
|
67
|
+
manifest,
|
|
68
|
+
locale,
|
|
69
|
+
basename,
|
|
70
|
+
strippedPathname,
|
|
71
|
+
}: {
|
|
72
|
+
url: string;
|
|
73
|
+
manifest: Manifest;
|
|
74
|
+
locale?: string;
|
|
75
|
+
basename?: string;
|
|
76
|
+
strippedPathname?: string;
|
|
77
|
+
}) {
|
|
78
|
+
this.url = url;
|
|
79
|
+
this.manifest = manifest;
|
|
80
|
+
this.locale = locale;
|
|
81
|
+
this.basename = basename;
|
|
82
|
+
this.strippedPathname = strippedPathname;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
head({
|
|
86
|
+
colorScheme,
|
|
87
|
+
req,
|
|
88
|
+
seo,
|
|
89
|
+
favicon,
|
|
90
|
+
}: Omit<RendererPipeParams, "options">) {
|
|
91
|
+
return ReactDOMServer.renderToString(
|
|
92
|
+
<HTML
|
|
93
|
+
pathname={req.originalUrl}
|
|
94
|
+
seoPathname={this.strippedPathname}
|
|
95
|
+
colorScheme={colorScheme}
|
|
96
|
+
manifest={this.manifest}
|
|
97
|
+
seo={seo}
|
|
98
|
+
favicon={favicon}
|
|
99
|
+
/>,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
pipe({
|
|
104
|
+
options,
|
|
105
|
+
registerModule,
|
|
106
|
+
colorScheme,
|
|
107
|
+
store,
|
|
108
|
+
trpcClient,
|
|
109
|
+
seoObject,
|
|
110
|
+
}: RendererPipeParams) {
|
|
111
|
+
return ReactDOMServer.renderToPipeableStream(
|
|
112
|
+
<StaticRouter location={this.url} basename={this.basename || ""}>
|
|
113
|
+
<App
|
|
114
|
+
colorScheme={colorScheme}
|
|
115
|
+
registerModule={registerModule}
|
|
116
|
+
store={store}
|
|
117
|
+
trpcClient={trpcClient}
|
|
118
|
+
seoObject={seoObject}
|
|
119
|
+
locale={this.locale}
|
|
120
|
+
eagerLocaleModules={localeModules}
|
|
121
|
+
viteModules={{
|
|
122
|
+
...pages,
|
|
123
|
+
...layouts,
|
|
124
|
+
...resetLayouts,
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
</StaticRouter>,
|
|
128
|
+
options,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import "@quark-fw/core/src/global.d.ts";
|
|
2
|
+
import { Atom } from "@quark-fw/atom";
|
|
3
|
+
|
|
4
|
+
export {};
|
|
5
|
+
|
|
6
|
+
declare global {
|
|
7
|
+
interface Quark {
|
|
8
|
+
react: {
|
|
9
|
+
port: Atom<number>;
|
|
10
|
+
/** значение <html lang> для приложений без i18n */
|
|
11
|
+
lang: Atom<string>;
|
|
12
|
+
/**
|
|
13
|
+
* Иконка вкладки: путь или data-URI. Пустая строка — не выводить
|
|
14
|
+
* ссылку вовсе.
|
|
15
|
+
*/
|
|
16
|
+
favicon: Atom<string>;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
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";
|
|
11
|
+
|
|
12
|
+
export type Translate = (
|
|
13
|
+
key: string,
|
|
14
|
+
params?: Record<string, string | number>,
|
|
15
|
+
) => string;
|
|
16
|
+
|
|
17
|
+
export type I18nContextValue = {
|
|
18
|
+
locale: string;
|
|
19
|
+
locales: readonly string[];
|
|
20
|
+
defaultLocale: string;
|
|
21
|
+
$: Translate;
|
|
22
|
+
setLocale: (locale: string) => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// дефолт для приложений без i18n: $ возвращает ключ, а не падает
|
|
26
|
+
const I18nContext = createContext<I18nContextValue>({
|
|
27
|
+
locale: "",
|
|
28
|
+
locales: [],
|
|
29
|
+
defaultLocale: "",
|
|
30
|
+
$: (key: string) => key,
|
|
31
|
+
setLocale: () => {},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const useI18n = () => useContext(I18nContext);
|
|
35
|
+
|
|
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
|
+
export default I18nContext;
|