@quark-fw/plugin-react 0.1.17 → 0.1.19
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quark-fw/plugin-react",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"main": "src/index.ts",
|
|
5
5
|
"types": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@quark-fw/atom": "0.1.
|
|
26
|
-
"@quark-fw/plugin": "0.1.
|
|
25
|
+
"@quark-fw/atom": "0.1.19",
|
|
26
|
+
"@quark-fw/plugin": "0.1.19",
|
|
27
27
|
"@trpc/client": "^11.0.0",
|
|
28
28
|
"@types/react-dom": "^19.2.3",
|
|
29
29
|
"express": "^5.2.1",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"@mantine/dates": "^9.0.0",
|
|
37
37
|
"@mantine/modals": "^9.0.0",
|
|
38
38
|
"@mantine/notifications": "^9.0.0",
|
|
39
|
-
"@quark-fw/core": "0.1.
|
|
40
|
-
"@quark-fw/store": "0.1.
|
|
39
|
+
"@quark-fw/core": "0.1.19",
|
|
40
|
+
"@quark-fw/store": "0.1.19",
|
|
41
41
|
"@vitejs/plugin-react": "^5.1.0",
|
|
42
42
|
"react": "^19.2.4",
|
|
43
43
|
"react-dom": "^19.2.4",
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,11 @@ await quark.registerPlugin({
|
|
|
61
61
|
if (fs.existsSync(staticDir)) app.use(express.static(staticDir));
|
|
62
62
|
app.use(getRenderMiddleware({}));
|
|
63
63
|
} else {
|
|
64
|
+
// в dev каталог static/ никто не отдавал: запрос /fonts/... проваливался
|
|
65
|
+
// в SSR и получал редирект локали вместо файла. В проде то же самое
|
|
66
|
+
// делает express.static из build/static.
|
|
67
|
+
const staticDir = `${process.cwd()}/static`;
|
|
68
|
+
if (fs.existsSync(staticDir)) app.use(express.static(staticDir));
|
|
64
69
|
vite = await getVite({
|
|
65
70
|
root: appFrontendRoot(),
|
|
66
71
|
appPort: quark.react.port.value,
|
package/src/seo/SeoProvider.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ReactNode, useEffect, useMemo, useState } from "react";
|
|
2
|
-
import { useLocation } from "react-router-dom";
|
|
2
|
+
import { useLocation, useNavigate } from "react-router-dom";
|
|
3
3
|
import { useStore } from "@quark-fw/store";
|
|
4
4
|
import { matchPath } from "react-router-dom";
|
|
5
5
|
import { getMatchedRoute } from "../Router/getMatchedRoute.js";
|
|
@@ -18,10 +18,19 @@ export function SeoProvider({
|
|
|
18
18
|
}) {
|
|
19
19
|
const [seoObject, setSeoObject] = useState<SeoMap>(initial ?? {});
|
|
20
20
|
const { pathname } = useLocation();
|
|
21
|
+
const navigate = useNavigate();
|
|
21
22
|
const store = useStore();
|
|
22
23
|
|
|
23
24
|
const current = seoObject[pathname];
|
|
24
25
|
|
|
26
|
+
// редирект из меты при клиентской навигации (на SSR его сделала мидлвара)
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const target = current?.redirect?.to;
|
|
29
|
+
if (!target) return;
|
|
30
|
+
if (target.startsWith("/")) navigate(target, { replace: true });
|
|
31
|
+
else window.location.replace(target);
|
|
32
|
+
}, [current?.redirect?.to, navigate]);
|
|
33
|
+
|
|
25
34
|
// клиентская навигация: меты для нового пути ещё нет — резолвим её
|
|
26
35
|
// из модуля страницы (тот же resolveMeta, что и на сервере)
|
|
27
36
|
useEffect(() => {
|
|
@@ -51,3 +51,11 @@ test("breadcrumbsJsonLd строит BreadcrumbList с абсолютными @i
|
|
|
51
51
|
],
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
test("resolveMeta резолвит redirect", async () => {
|
|
56
|
+
const seo = await resolveMeta(
|
|
57
|
+
{ redirect: async () => ({ to: "/listing/new", status: 301 }) },
|
|
58
|
+
{ store: {} as never, params: {} },
|
|
59
|
+
);
|
|
60
|
+
expect(seo.redirect).toEqual({ to: "/listing/new", status: 301 });
|
|
61
|
+
});
|
|
@@ -34,6 +34,9 @@ export type JsonLd = Record<string, unknown>;
|
|
|
34
34
|
/** крошка навигации: подпись и путь относительно basename роутера */
|
|
35
35
|
export type PageMetaBreadcrumb = { label: string; to: string };
|
|
36
36
|
|
|
37
|
+
/** редирект вместо рендера: путь относительно basename (или абсолютный URL) и статус */
|
|
38
|
+
export type PageMetaRedirect = { to: string; status?: 301 | 302 | 307 | 308 };
|
|
39
|
+
|
|
37
40
|
export type PageMeta = {
|
|
38
41
|
title?: MetaValue<string>;
|
|
39
42
|
description?: MetaValue<string>;
|
|
@@ -49,6 +52,12 @@ export type PageMeta = {
|
|
|
49
52
|
breadcrumbs?: MetaValue<PageMetaBreadcrumb[] | undefined>;
|
|
50
53
|
/** HTTP-статус ответа (например 404 для «не найдено») */
|
|
51
54
|
status?: MetaValue<number>;
|
|
55
|
+
/**
|
|
56
|
+
* Редирект: на SSR — ответ 30x до рендера, при клиентской навигации —
|
|
57
|
+
* navigate(replace). Для перенесённых и схлопнутых страниц (дубликаты,
|
|
58
|
+
* смена slug), чтобы не отдавать 404 по проиндексированным адресам.
|
|
59
|
+
*/
|
|
60
|
+
redirect?: MetaValue<PageMetaRedirect | undefined>;
|
|
52
61
|
};
|
|
53
62
|
|
|
54
63
|
export type ResolvedSeo = {
|
|
@@ -59,6 +68,7 @@ export type ResolvedSeo = {
|
|
|
59
68
|
jsonLD?: JsonLd[];
|
|
60
69
|
breadcrumbs?: PageMetaBreadcrumb[];
|
|
61
70
|
status?: number;
|
|
71
|
+
redirect?: PageMetaRedirect;
|
|
62
72
|
};
|
|
63
73
|
|
|
64
74
|
export function createPageMeta(meta: PageMeta): PageMeta {
|
|
@@ -82,17 +92,35 @@ export async function resolveMeta(
|
|
|
82
92
|
ctx: PageMetaContext,
|
|
83
93
|
): Promise<ResolvedSeo> {
|
|
84
94
|
if (!meta) return {};
|
|
85
|
-
const [
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
const [
|
|
96
|
+
title,
|
|
97
|
+
description,
|
|
98
|
+
robots,
|
|
99
|
+
og,
|
|
100
|
+
jsonLD,
|
|
101
|
+
breadcrumbs,
|
|
102
|
+
status,
|
|
103
|
+
redirect,
|
|
104
|
+
] = await Promise.all([
|
|
105
|
+
resolveValue(meta.title, ctx),
|
|
106
|
+
resolveValue(meta.description, ctx),
|
|
107
|
+
resolveValue(meta.robots, ctx),
|
|
108
|
+
resolveValue(meta.og, ctx),
|
|
109
|
+
resolveValue(meta.jsonLD, ctx),
|
|
110
|
+
resolveValue(meta.breadcrumbs, ctx),
|
|
111
|
+
resolveValue(meta.status, ctx),
|
|
112
|
+
resolveValue(meta.redirect, ctx),
|
|
113
|
+
]);
|
|
114
|
+
return {
|
|
115
|
+
title,
|
|
116
|
+
description,
|
|
117
|
+
robots,
|
|
118
|
+
og,
|
|
119
|
+
jsonLD,
|
|
120
|
+
breadcrumbs,
|
|
121
|
+
status,
|
|
122
|
+
redirect,
|
|
123
|
+
};
|
|
96
124
|
}
|
|
97
125
|
|
|
98
126
|
/** Узел BreadcrumbList для <head>; linkBase — origin + basename роутера. */
|
|
@@ -3,34 +3,51 @@ import type { Response } from "express";
|
|
|
3
3
|
import { UglyModule } from "../Router/UglyModule.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Пишет в ответ preload-теги
|
|
7
|
-
*
|
|
6
|
+
* Пишет в ответ preload-теги для модулей страницы, которые регистрирует
|
|
7
|
+
* роутер по мере рендера.
|
|
8
|
+
*
|
|
9
|
+
* modulepreload — только на чанк самого модуля и его прямые импорты: этого
|
|
10
|
+
* хватает, чтобы гидрация не ждала лишний хоп сети. Раньше обход шёл по
|
|
11
|
+
* всему графу импортов, и главная страница bina получала ~30 preload-ов на
|
|
12
|
+
* ~200 KB gzip, большую часть которых первый экран не исполнял. CSS, напротив,
|
|
13
|
+
* собирается по всему графу: стили вложенных чанков нужны к первому кадру,
|
|
14
|
+
* иначе разметка мигает без оформления. Каждый тег — один раз.
|
|
8
15
|
*/
|
|
9
16
|
export function createPreloadTagWriter(manifest: Manifest, res: Response) {
|
|
10
|
-
const
|
|
11
|
-
const written: Record<string, boolean> = {};
|
|
17
|
+
const written = new Set<string>();
|
|
12
18
|
|
|
13
|
-
const
|
|
19
|
+
const writeOnce = (id: string, tag: string) => {
|
|
20
|
+
if (written.has(id)) return;
|
|
21
|
+
written.add(id);
|
|
22
|
+
res.write(tag);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const writeCss = (link: string, seen = new Set<string>()): void => {
|
|
26
|
+
if (seen.has(link)) return;
|
|
27
|
+
seen.add(link);
|
|
14
28
|
const chunk = manifest[link];
|
|
15
|
-
if (chunk
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (!written[cssFile]) {
|
|
21
|
-
written[cssFile] = true;
|
|
22
|
-
res.write(`<link rel="stylesheet" href="/${cssFile}"/>`);
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
(chunk.imports ?? []).map((childLink) => extractTags(childLink));
|
|
26
|
-
}
|
|
29
|
+
if (!chunk) return;
|
|
30
|
+
(chunk.css ?? []).forEach((cssFile) =>
|
|
31
|
+
writeOnce(cssFile, `<link rel="stylesheet" href="/${cssFile}"/>`),
|
|
32
|
+
);
|
|
33
|
+
(chunk.imports ?? []).forEach((child) => writeCss(child, seen));
|
|
27
34
|
};
|
|
28
35
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
const writeModule = (link: string) => {
|
|
37
|
+
const chunk = manifest[link];
|
|
38
|
+
if (!chunk || chunk.name === "entry-client") return;
|
|
39
|
+
writeOnce(
|
|
40
|
+
chunk.file,
|
|
41
|
+
`<link rel="modulepreload" as="script" crossorigin href="/${chunk.file}">`,
|
|
42
|
+
);
|
|
43
|
+
};
|
|
32
44
|
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
return function registerModule(uglyModule: UglyModule) {
|
|
46
|
+
const link = uglyModule.modulePath.substring(1);
|
|
47
|
+
const chunk = manifest[link];
|
|
48
|
+
if (!chunk || chunk.name === "entry-client") return;
|
|
49
|
+
writeModule(link);
|
|
50
|
+
(chunk.imports ?? []).forEach(writeModule);
|
|
51
|
+
writeCss(link);
|
|
35
52
|
};
|
|
36
53
|
}
|
|
@@ -110,6 +110,16 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
110
110
|
origin,
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
// редирект из меты: страница переехала или схлопнута — отвечаем
|
|
114
|
+
// 30x до рендера, относительный путь дополняется basename локали
|
|
115
|
+
if (seo.redirect) {
|
|
116
|
+
const target = seo.redirect.to.startsWith("/")
|
|
117
|
+
? basename + seo.redirect.to
|
|
118
|
+
: seo.redirect.to;
|
|
119
|
+
res.redirect(seo.redirect.status ?? 302, target);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
113
123
|
res.status(seo.status ?? (page ? 200 : 404));
|
|
114
124
|
|
|
115
125
|
// hreflang-кластер: каждая локаль + x-default на дефолтную
|