@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.
@@ -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
+ }
@@ -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;