@quark-fw/plugin-react 0.1.7 → 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
package/src/seo/SeoContext.tsx
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
ReactNode,
|
|
4
|
-
useContext,
|
|
5
|
-
useEffect,
|
|
6
|
-
useMemo,
|
|
7
|
-
useState,
|
|
8
|
-
} from "react";
|
|
9
|
-
import { useLocation } from "react-router-dom";
|
|
10
|
-
import { useStore } from "@quark-fw/store";
|
|
11
|
-
import { matchPath } from "react-router-dom";
|
|
12
|
-
import { getMatchedRoute } from "../Router/getMatchedRoute.js";
|
|
13
|
-
import type { PageMeta, ResolvedSeo } from "./createPageMeta.js";
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { ResolvedSeo } from "./createPageMeta.js";
|
|
14
3
|
|
|
15
4
|
export type SeoMap = Record<string, ResolvedSeo>;
|
|
16
5
|
|
|
@@ -26,69 +15,4 @@ const SeoContext = createContext<SeoContextValue>({
|
|
|
26
15
|
|
|
27
16
|
export const useSeo = () => useContext(SeoContext);
|
|
28
17
|
|
|
29
|
-
export function SeoProvider({
|
|
30
|
-
children,
|
|
31
|
-
seoObject: initial,
|
|
32
|
-
}: {
|
|
33
|
-
children: ReactNode;
|
|
34
|
-
seoObject?: SeoMap;
|
|
35
|
-
}) {
|
|
36
|
-
const [seoObject, setSeoObject] = useState<SeoMap>(initial ?? {});
|
|
37
|
-
const { pathname } = useLocation();
|
|
38
|
-
const store = useStore();
|
|
39
|
-
|
|
40
|
-
const current = seoObject[pathname];
|
|
41
|
-
|
|
42
|
-
// клиентская навигация: меты для нового пути ещё нет — резолвим её
|
|
43
|
-
// из модуля страницы (тот же resolveMeta, что и на сервере)
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
if (current) return;
|
|
46
|
-
let cancelled = false;
|
|
47
|
-
void (async () => {
|
|
48
|
-
const uglyModule = getMatchedRoute(pathname);
|
|
49
|
-
if (!uglyModule) return;
|
|
50
|
-
const loaded = await uglyModule.load();
|
|
51
|
-
const meta = (loaded?.default as { meta?: PageMeta } | undefined)
|
|
52
|
-
?.meta;
|
|
53
|
-
if (!meta || cancelled) return;
|
|
54
|
-
const routeProps = matchPath(
|
|
55
|
-
{ path: uglyModule.urlPath, end: false, caseSensitive: false },
|
|
56
|
-
pathname,
|
|
57
|
-
);
|
|
58
|
-
const { resolveMeta } = await import("./createPageMeta.js");
|
|
59
|
-
const resolved = await resolveMeta(meta, {
|
|
60
|
-
store,
|
|
61
|
-
params: (routeProps?.params ?? {}) as Record<string, string>,
|
|
62
|
-
});
|
|
63
|
-
if (!cancelled) {
|
|
64
|
-
setSeoObject((prev) => ({ ...prev, [pathname]: resolved }));
|
|
65
|
-
}
|
|
66
|
-
})();
|
|
67
|
-
return () => {
|
|
68
|
-
cancelled = true;
|
|
69
|
-
};
|
|
70
|
-
// store живёт всё время жизни приложения и меняться не может, а его
|
|
71
|
-
// добавление в зависимости ничего бы не изменило
|
|
72
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
73
|
-
}, [pathname, current]);
|
|
74
|
-
|
|
75
|
-
// заголовок вкладки при клиентской навигации
|
|
76
|
-
useEffect(() => {
|
|
77
|
-
if (typeof document === "undefined") return;
|
|
78
|
-
if (current?.title) document.title = current.title;
|
|
79
|
-
}, [current?.title]);
|
|
80
|
-
|
|
81
|
-
const value = useMemo<SeoContextValue>(
|
|
82
|
-
() => ({
|
|
83
|
-
...(current ?? {}),
|
|
84
|
-
seoObject,
|
|
85
|
-
setPathnameSeo: (path, seo) =>
|
|
86
|
-
setSeoObject((prev) => ({ ...prev, [path]: seo })),
|
|
87
|
-
}),
|
|
88
|
-
[current, seoObject],
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
return <SeoContext.Provider value={value}>{children}</SeoContext.Provider>;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
18
|
export default SeoContext;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { ReactNode, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useLocation } from "react-router-dom";
|
|
3
|
+
import { useStore } from "@quark-fw/store";
|
|
4
|
+
import { matchPath } from "react-router-dom";
|
|
5
|
+
import { getMatchedRoute } from "../Router/getMatchedRoute.js";
|
|
6
|
+
import type { PageMeta } from "./createPageMeta.js";
|
|
7
|
+
import SeoContext, { type SeoContextValue, type SeoMap } from "./SeoContext.js";
|
|
8
|
+
|
|
9
|
+
export function SeoProvider({
|
|
10
|
+
children,
|
|
11
|
+
seoObject: initial,
|
|
12
|
+
}: {
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
seoObject?: SeoMap;
|
|
15
|
+
}) {
|
|
16
|
+
const [seoObject, setSeoObject] = useState<SeoMap>(initial ?? {});
|
|
17
|
+
const { pathname } = useLocation();
|
|
18
|
+
const store = useStore();
|
|
19
|
+
|
|
20
|
+
const current = seoObject[pathname];
|
|
21
|
+
|
|
22
|
+
// клиентская навигация: меты для нового пути ещё нет — резолвим её
|
|
23
|
+
// из модуля страницы (тот же resolveMeta, что и на сервере)
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (current) return;
|
|
26
|
+
let cancelled = false;
|
|
27
|
+
void (async () => {
|
|
28
|
+
const uglyModule = getMatchedRoute(pathname);
|
|
29
|
+
if (!uglyModule) return;
|
|
30
|
+
const loaded = await uglyModule.load();
|
|
31
|
+
const meta = (loaded?.default as { meta?: PageMeta } | undefined)
|
|
32
|
+
?.meta;
|
|
33
|
+
if (!meta || cancelled) return;
|
|
34
|
+
const routeProps = matchPath(
|
|
35
|
+
{ path: uglyModule.urlPath, end: false, caseSensitive: false },
|
|
36
|
+
pathname,
|
|
37
|
+
);
|
|
38
|
+
const { resolveMeta } = await import("./createPageMeta.js");
|
|
39
|
+
const resolved = await resolveMeta(meta, {
|
|
40
|
+
store,
|
|
41
|
+
params: (routeProps?.params ?? {}) as Record<string, string>,
|
|
42
|
+
});
|
|
43
|
+
if (!cancelled) {
|
|
44
|
+
setSeoObject((prev) => ({ ...prev, [pathname]: resolved }));
|
|
45
|
+
}
|
|
46
|
+
})();
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
};
|
|
50
|
+
// store живёт всё время жизни приложения и меняться не может, а его
|
|
51
|
+
// добавление в зависимости ничего бы не изменило
|
|
52
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
53
|
+
}, [pathname, current]);
|
|
54
|
+
|
|
55
|
+
// заголовок вкладки при клиентской навигации
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (typeof document === "undefined") return;
|
|
58
|
+
if (current?.title) document.title = current.title;
|
|
59
|
+
}, [current?.title]);
|
|
60
|
+
|
|
61
|
+
const value = useMemo<SeoContextValue>(
|
|
62
|
+
() => ({
|
|
63
|
+
...(current ?? {}),
|
|
64
|
+
seoObject,
|
|
65
|
+
setPathnameSeo: (path, seo) =>
|
|
66
|
+
setSeoObject((prev) => ({ ...prev, [path]: seo })),
|
|
67
|
+
}),
|
|
68
|
+
[current, seoObject],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return <SeoContext.Provider value={value}>{children}</SeoContext.Provider>;
|
|
72
|
+
}
|
|
@@ -3,7 +3,11 @@ import { Request, Response } from "express";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import { createTRPCClient, httpLink } from "@trpc/client";
|
|
6
|
-
import Store, {
|
|
6
|
+
import Store, {
|
|
7
|
+
jsonSafeFetch,
|
|
8
|
+
NOT_READY_STATE,
|
|
9
|
+
type TRPCClient,
|
|
10
|
+
} from "@quark-fw/store";
|
|
7
11
|
import { UglyModule } from "../Router/gatherPages.js";
|
|
8
12
|
import removeSearchParamsFromUrl from "../utils/removeSearchParamsFromUrl.js";
|
|
9
13
|
import serializeForScript from "../utils/serializeForScript.js";
|
|
@@ -188,9 +192,9 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
188
192
|
callback: (key, value) => {
|
|
189
193
|
const safeKey = serializeForScript(key);
|
|
190
194
|
void htmlReady.then(() => {
|
|
191
|
-
if (value ===
|
|
195
|
+
if (value === NOT_READY_STATE) {
|
|
192
196
|
res.write(
|
|
193
|
-
`<script>window.cache=window.cache||{};window.cache[${safeKey}]
|
|
197
|
+
`<script>window.cache=window.cache||{};window.cache[${safeKey}]=${serializeForScript(NOT_READY_STATE)};</script>`,
|
|
194
198
|
);
|
|
195
199
|
} else {
|
|
196
200
|
// undefined сериализуем как null: literal undefined в
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { InlineConfig } from "vite";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
import tsconfigPaths from "vite-tsconfig-paths";
|
|
4
|
+
import svgr from "vite-plugin-svgr";
|
|
5
|
+
import { patchCssModules } from "vite-css-modules";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { loadModulesFromExternalFolderPlugin } from "./plugins.js";
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Пакеты @quark/* в приложении — это симлинки на ИСХОДНИКИ соседних
|
|
15
|
+
* workspace-пакетов. Если позволить vite их пре-бандлить, он кэширует
|
|
16
|
+
* результат в node_modules/.vite и продолжает отдавать старую версию даже
|
|
17
|
+
* после правок в пакете (правка видна только после ручной очистки кэша).
|
|
18
|
+
* Поэтому исключаем их из оптимизации — тогда HMR работает как для
|
|
19
|
+
* обычных файлов приложения.
|
|
20
|
+
*/
|
|
21
|
+
const workspacePackages = (cwd: string): string[] => {
|
|
22
|
+
try {
|
|
23
|
+
const pkg = JSON.parse(
|
|
24
|
+
fs.readFileSync(path.join(cwd, "package.json"), "utf8"),
|
|
25
|
+
) as Record<string, Record<string, string> | undefined>;
|
|
26
|
+
return Object.keys({
|
|
27
|
+
...(pkg.dependencies ?? {}),
|
|
28
|
+
...(pkg.devDependencies ?? {}),
|
|
29
|
+
}).filter(
|
|
30
|
+
(name) =>
|
|
31
|
+
name.startsWith("@quark/") || name.startsWith("@quark-fw/"),
|
|
32
|
+
);
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Плагин поставлен из npm (лежит внутри node_modules), а не симлинкован из монорепы. */
|
|
39
|
+
const installedFromRegistry = fs
|
|
40
|
+
.realpathSync(__dirname)
|
|
41
|
+
.replace(/\\/g, "/")
|
|
42
|
+
.includes("/node_modules/");
|
|
43
|
+
|
|
44
|
+
/** Единая конфигурация vite для dev-сервера и для сборки. */
|
|
45
|
+
export function createViteConfig(overrides?: InlineConfig): InlineConfig {
|
|
46
|
+
return {
|
|
47
|
+
plugins: [
|
|
48
|
+
patchCssModules(),
|
|
49
|
+
svgr({ include: "**/*.svg" }),
|
|
50
|
+
react(),
|
|
51
|
+
tsconfigPaths(),
|
|
52
|
+
loadModulesFromExternalFolderPlugin(),
|
|
53
|
+
],
|
|
54
|
+
// Установленный из реестра плагин лежит в node_modules, куда
|
|
55
|
+
// @vitejs/plugin-react не заглядывает (у него exclude /node_modules/).
|
|
56
|
+
// Без явного automatic-рантайма его собственный JSX собирается в
|
|
57
|
+
// React.createElement и падает с "React is not defined".
|
|
58
|
+
esbuild: { jsx: "automatic", jsxImportSource: "react" },
|
|
59
|
+
optimizeDeps: {
|
|
60
|
+
exclude: workspacePackages(process.cwd()),
|
|
61
|
+
// Плагин исключён из оптимизации, поэтому vite не сканирует его
|
|
62
|
+
// импорты: установленный из реестра, он тянет react-router и mantine
|
|
63
|
+
// сырыми, и CJS-пакеты внутри них (cookie) падают на named-импортах.
|
|
64
|
+
// В монорепо, наоборот, форсировать пре-бандл нельзя — рядом с
|
|
65
|
+
// симлинкованными исходниками появилась бы вторая копия React.
|
|
66
|
+
include: installedFromRegistry
|
|
67
|
+
? [
|
|
68
|
+
"react",
|
|
69
|
+
"react-dom",
|
|
70
|
+
"react-dom/client",
|
|
71
|
+
"react-router-dom",
|
|
72
|
+
"@mantine/core",
|
|
73
|
+
"@mantine/dates",
|
|
74
|
+
"@mantine/modals",
|
|
75
|
+
"@mantine/notifications",
|
|
76
|
+
]
|
|
77
|
+
: [],
|
|
78
|
+
},
|
|
79
|
+
...overrides,
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/utils/vite.ts
CHANGED
package/src/utils/viteConfig.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import tsconfigPaths from "vite-tsconfig-paths";
|
|
4
|
-
import svgr from "vite-plugin-svgr";
|
|
5
|
-
import { patchCssModules } from "vite-css-modules";
|
|
6
|
-
import fs from "node:fs";
|
|
1
|
+
// Пути фронтенда и сборки (хелперы). Сама конфигурация vite —
|
|
2
|
+
// в createViteConfig.ts.
|
|
7
3
|
import path from "node:path";
|
|
8
4
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { loadModulesFromExternalFolderPlugin } from "./plugins.js";
|
|
10
5
|
|
|
11
6
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
7
|
|
|
@@ -28,72 +23,3 @@ export const buildPaths = (cwd = process.cwd()) => ({
|
|
|
28
23
|
manifest: path.join(cwd, "build/client/.vite/manifest.json"),
|
|
29
24
|
serverEntry: path.join(cwd, "build/server/entry-server.js"),
|
|
30
25
|
});
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Пакеты @quark/* в приложении — это симлинки на ИСХОДНИКИ соседних
|
|
34
|
-
* workspace-пакетов. Если позволить vite их пре-бандлить, он кэширует
|
|
35
|
-
* результат в node_modules/.vite и продолжает отдавать старую версию даже
|
|
36
|
-
* после правок в пакете (правка видна только после ручной очистки кэша).
|
|
37
|
-
* Поэтому исключаем их из оптимизации — тогда HMR работает как для
|
|
38
|
-
* обычных файлов приложения.
|
|
39
|
-
*/
|
|
40
|
-
const workspacePackages = (cwd: string): string[] => {
|
|
41
|
-
try {
|
|
42
|
-
const pkg = JSON.parse(
|
|
43
|
-
fs.readFileSync(path.join(cwd, "package.json"), "utf8"),
|
|
44
|
-
) as Record<string, Record<string, string> | undefined>;
|
|
45
|
-
return Object.keys({
|
|
46
|
-
...(pkg.dependencies ?? {}),
|
|
47
|
-
...(pkg.devDependencies ?? {}),
|
|
48
|
-
}).filter(
|
|
49
|
-
(name) => name.startsWith("@quark/") || name.startsWith("@quark-fw/"),
|
|
50
|
-
);
|
|
51
|
-
} catch {
|
|
52
|
-
return [];
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
/** Плагин поставлен из npm (лежит внутри node_modules), а не симлинкован из монорепы. */
|
|
57
|
-
const installedFromRegistry = fs
|
|
58
|
-
.realpathSync(__dirname)
|
|
59
|
-
.replace(/\\/g, "/")
|
|
60
|
-
.includes("/node_modules/");
|
|
61
|
-
|
|
62
|
-
/** Единая конфигурация vite для dev-сервера и для сборки. */
|
|
63
|
-
export function createViteConfig(overrides?: InlineConfig): InlineConfig {
|
|
64
|
-
return {
|
|
65
|
-
plugins: [
|
|
66
|
-
patchCssModules(),
|
|
67
|
-
svgr({ include: "**/*.svg" }),
|
|
68
|
-
react(),
|
|
69
|
-
tsconfigPaths(),
|
|
70
|
-
loadModulesFromExternalFolderPlugin(),
|
|
71
|
-
],
|
|
72
|
-
// Установленный из реестра плагин лежит в node_modules, куда
|
|
73
|
-
// @vitejs/plugin-react не заглядывает (у него exclude /node_modules/).
|
|
74
|
-
// Без явного automatic-рантайма его собственный JSX собирается в
|
|
75
|
-
// React.createElement и падает с "React is not defined".
|
|
76
|
-
esbuild: { jsx: "automatic", jsxImportSource: "react" },
|
|
77
|
-
optimizeDeps: {
|
|
78
|
-
exclude: workspacePackages(process.cwd()),
|
|
79
|
-
// Плагин исключён из оптимизации, поэтому vite не сканирует его
|
|
80
|
-
// импорты: установленный из реестра, он тянет react-router и mantine
|
|
81
|
-
// сырыми, и CJS-пакеты внутри них (cookie) падают на named-импортах.
|
|
82
|
-
// В монорепо, наоборот, форсировать пре-бандл нельзя — рядом с
|
|
83
|
-
// симлинкованными исходниками появилась бы вторая копия React.
|
|
84
|
-
include: installedFromRegistry
|
|
85
|
-
? [
|
|
86
|
-
"react",
|
|
87
|
-
"react-dom",
|
|
88
|
-
"react-dom/client",
|
|
89
|
-
"react-router-dom",
|
|
90
|
-
"@mantine/core",
|
|
91
|
-
"@mantine/dates",
|
|
92
|
-
"@mantine/modals",
|
|
93
|
-
"@mantine/notifications",
|
|
94
|
-
]
|
|
95
|
-
: [],
|
|
96
|
-
},
|
|
97
|
-
...overrides,
|
|
98
|
-
};
|
|
99
|
-
}
|
package/src/utils/usePromise.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
export function usePromise<T>(promise: Promise<T>): () => T {
|
|
2
|
-
let status = "pending";
|
|
3
|
-
let response: T;
|
|
4
|
-
const suspender = promise.then(
|
|
5
|
-
(res) => {
|
|
6
|
-
status = "success";
|
|
7
|
-
response = res;
|
|
8
|
-
},
|
|
9
|
-
(err) => {
|
|
10
|
-
status = "error";
|
|
11
|
-
response = err as never;
|
|
12
|
-
},
|
|
13
|
-
);
|
|
14
|
-
return () => {
|
|
15
|
-
switch (status) {
|
|
16
|
-
case "pending":
|
|
17
|
-
throw suspender;
|
|
18
|
-
case "error":
|
|
19
|
-
throw response;
|
|
20
|
-
default:
|
|
21
|
-
return response;
|
|
22
|
-
}
|
|
23
|
-
};
|
|
24
|
-
}
|