@quark-fw/plugin-react 0.1.22 → 0.1.24
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 +5 -5
- package/src/frontend/HTML/index.tsx +22 -4
- package/src/frontend/entry-server.tsx +10 -1
- package/src/seo/SeoProvider.tsx +8 -4
- package/src/seo/createPageMeta.test.ts +18 -0
- package/src/seo/createPageMeta.ts +15 -0
- package/src/ssr/getRenderMiddleware.ts +19 -4
- package/src/utils/parseSearch.test.ts +20 -0
- package/src/utils/parseSearch.ts +27 -0
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.24",
|
|
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.24",
|
|
26
|
+
"@quark-fw/plugin": "0.1.24",
|
|
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.24",
|
|
40
|
+
"@quark-fw/store": "0.1.24",
|
|
41
41
|
"@vitejs/plugin-react": "^5.1.0",
|
|
42
42
|
"react": "^19.2.4",
|
|
43
43
|
"react-dom": "^19.2.4",
|
|
@@ -15,7 +15,7 @@ export type HTMLProps = {
|
|
|
15
15
|
colorScheme: "dark" | "light" | undefined;
|
|
16
16
|
manifest: Manifest;
|
|
17
17
|
pathname?: string;
|
|
18
|
-
/** путь БЕЗ префикса локали — по
|
|
18
|
+
/** путь БЕЗ префикса локали + search — ключ, по которому SeoProvider ищет мету */
|
|
19
19
|
seoPathname?: string;
|
|
20
20
|
seo?: ResolvedSeo;
|
|
21
21
|
/** иконка вкладки; приложение задаёт её атомом quark.react.favicon */
|
|
@@ -40,8 +40,16 @@ function HTML({
|
|
|
40
40
|
linkBase,
|
|
41
41
|
}: HTMLProps) {
|
|
42
42
|
const path = removeSearchParamsFromUrl(pathname!);
|
|
43
|
-
//
|
|
44
|
-
|
|
43
|
+
// canonical из меты: относительный (от basename) дополняется linkBase,
|
|
44
|
+
// абсолютный берётся как есть; без меты — путь без query. Без origin
|
|
45
|
+
// остаётся относительный URL — как было до появления поля
|
|
46
|
+
const canonical = seo?.canonical
|
|
47
|
+
? seo.canonical.startsWith("/")
|
|
48
|
+
? (linkBase ?? "") + seo.canonical
|
|
49
|
+
: seo.canonical
|
|
50
|
+
: origin
|
|
51
|
+
? origin + path
|
|
52
|
+
: path;
|
|
45
53
|
// относительная картинка og:image без origin бесполезна для соцсетей
|
|
46
54
|
const abs = (url?: string) =>
|
|
47
55
|
url && url.startsWith("/") && origin ? origin + url : url;
|
|
@@ -86,7 +94,17 @@ function HTML({
|
|
|
86
94
|
/>
|
|
87
95
|
) : null}
|
|
88
96
|
{og?.image ? (
|
|
89
|
-
|
|
97
|
+
<>
|
|
98
|
+
<meta
|
|
99
|
+
property="og:image"
|
|
100
|
+
content={abs(og.image)}
|
|
101
|
+
/>
|
|
102
|
+
{/* Twitter/X не читает og:image без twitter:card */}
|
|
103
|
+
<meta
|
|
104
|
+
name="twitter:card"
|
|
105
|
+
content="summary_large_image"
|
|
106
|
+
/>
|
|
107
|
+
</>
|
|
90
108
|
) : null}
|
|
91
109
|
{og?.siteName ? (
|
|
92
110
|
<meta
|
|
@@ -65,6 +65,8 @@ export class Renderer {
|
|
|
65
65
|
locale?: string;
|
|
66
66
|
basename?: string;
|
|
67
67
|
strippedPathname?: string;
|
|
68
|
+
/** нормализованный search запроса ("" или "?a=1") — часть ключа SEO-карты */
|
|
69
|
+
search: string;
|
|
68
70
|
|
|
69
71
|
constructor({
|
|
70
72
|
url,
|
|
@@ -72,18 +74,21 @@ export class Renderer {
|
|
|
72
74
|
locale,
|
|
73
75
|
basename,
|
|
74
76
|
strippedPathname,
|
|
77
|
+
search = "",
|
|
75
78
|
}: {
|
|
76
79
|
url: string;
|
|
77
80
|
manifest: Manifest;
|
|
78
81
|
locale?: string;
|
|
79
82
|
basename?: string;
|
|
80
83
|
strippedPathname?: string;
|
|
84
|
+
search?: string;
|
|
81
85
|
}) {
|
|
82
86
|
this.url = url;
|
|
83
87
|
this.manifest = manifest;
|
|
84
88
|
this.locale = locale;
|
|
85
89
|
this.basename = basename;
|
|
86
90
|
this.strippedPathname = strippedPathname;
|
|
91
|
+
this.search = search;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
head({
|
|
@@ -97,7 +102,11 @@ export class Renderer {
|
|
|
97
102
|
return ReactDOMServer.renderToString(
|
|
98
103
|
<HTML
|
|
99
104
|
pathname={req.originalUrl}
|
|
100
|
-
seoPathname={
|
|
105
|
+
seoPathname={
|
|
106
|
+
this.strippedPathname === undefined
|
|
107
|
+
? undefined
|
|
108
|
+
: this.strippedPathname + this.search
|
|
109
|
+
}
|
|
101
110
|
colorScheme={colorScheme}
|
|
102
111
|
manifest={this.manifest}
|
|
103
112
|
seo={seo}
|
package/src/seo/SeoProvider.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { matchPath } from "react-router-dom";
|
|
|
5
5
|
import { getMatchedRoute } from "../Router/getMatchedRoute.js";
|
|
6
6
|
import type { PageMeta } from "./createPageMeta.js";
|
|
7
7
|
import SeoContext, { type SeoContextValue, type SeoMap } from "./SeoContext.js";
|
|
8
|
+
import { parseSearch, seoKey } from "../utils/parseSearch.js";
|
|
8
9
|
|
|
9
10
|
export function SeoProvider({
|
|
10
11
|
children,
|
|
@@ -17,11 +18,13 @@ export function SeoProvider({
|
|
|
17
18
|
locale?: string;
|
|
18
19
|
}) {
|
|
19
20
|
const [seoObject, setSeoObject] = useState<SeoMap>(initial ?? {});
|
|
20
|
-
const { pathname } = useLocation();
|
|
21
|
+
const { pathname, search } = useLocation();
|
|
21
22
|
const navigate = useNavigate();
|
|
22
23
|
const store = useStore();
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
// ключ с search: ?page=2 — другая мета (title, canonical), чем страница 1
|
|
26
|
+
const key = seoKey(pathname, search);
|
|
27
|
+
const current = seoObject[key];
|
|
25
28
|
|
|
26
29
|
// редирект из меты при клиентской навигации (на SSR его сделала мидлвара)
|
|
27
30
|
useEffect(() => {
|
|
@@ -56,9 +59,10 @@ export function SeoProvider({
|
|
|
56
59
|
typeof window === "undefined"
|
|
57
60
|
? undefined
|
|
58
61
|
: window.location.origin,
|
|
62
|
+
query: parseSearch(search).query,
|
|
59
63
|
});
|
|
60
64
|
if (!cancelled) {
|
|
61
|
-
setSeoObject((prev) => ({ ...prev, [
|
|
65
|
+
setSeoObject((prev) => ({ ...prev, [key]: resolved }));
|
|
62
66
|
}
|
|
63
67
|
})();
|
|
64
68
|
return () => {
|
|
@@ -68,7 +72,7 @@ export function SeoProvider({
|
|
|
68
72
|
// добавление в зависимости ничего бы не изменило; locale меняется
|
|
69
73
|
// только полной перезагрузкой (setLocale)
|
|
70
74
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
71
|
-
}, [
|
|
75
|
+
}, [key, current]);
|
|
72
76
|
|
|
73
77
|
// заголовок вкладки при клиентской навигации
|
|
74
78
|
useEffect(() => {
|
|
@@ -23,6 +23,24 @@ test("resolveMeta передаёт locale и origin в функции меты
|
|
|
23
23
|
expect(seo.og?.locale).toBe("ru_RU");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
test("resolveMeta передаёт query и резолвит canonical", async () => {
|
|
27
|
+
const seo = await resolveMeta(
|
|
28
|
+
{
|
|
29
|
+
canonical: ({ query }) =>
|
|
30
|
+
Number(query?.["page"]) > 1
|
|
31
|
+
? `/tbilisi?page=${query!["page"]}`
|
|
32
|
+
: undefined,
|
|
33
|
+
},
|
|
34
|
+
{ store: {} as never, params: {}, query: { page: "2" } },
|
|
35
|
+
);
|
|
36
|
+
expect(seo.canonical).toBe("/tbilisi?page=2");
|
|
37
|
+
const first = await resolveMeta(
|
|
38
|
+
{ canonical: ({ query }) => (query?.["page"] ? "/x" : undefined) },
|
|
39
|
+
{ store: {} as never, params: {} },
|
|
40
|
+
);
|
|
41
|
+
expect(first.canonical).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
26
44
|
test("breadcrumbsJsonLd строит BreadcrumbList с абсолютными @id", () => {
|
|
27
45
|
const node = breadcrumbsJsonLd(
|
|
28
46
|
[
|
|
@@ -14,6 +14,11 @@ export type PageMetaContext = {
|
|
|
14
14
|
* на клиенте — из window.location.
|
|
15
15
|
*/
|
|
16
16
|
origin?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Query-параметры URL (первое значение каждого). Для страниц, чья мета
|
|
19
|
+
* зависит от запроса — например пагинация каталога (?page=N).
|
|
20
|
+
*/
|
|
21
|
+
query?: Record<string, string>;
|
|
17
22
|
};
|
|
18
23
|
|
|
19
24
|
export type MetaValue<T> = T | ((ctx: PageMetaContext) => T | Promise<T>);
|
|
@@ -58,6 +63,12 @@ export type PageMeta = {
|
|
|
58
63
|
* смена slug), чтобы не отдавать 404 по проиндексированным адресам.
|
|
59
64
|
*/
|
|
60
65
|
redirect?: MetaValue<PageMetaRedirect | undefined>;
|
|
66
|
+
/**
|
|
67
|
+
* Canonical страницы: путь относительно basename (может содержать query,
|
|
68
|
+
* например `/tbilisi?page=2`) или абсолютный URL. По умолчанию — путь
|
|
69
|
+
* страницы без query. hreflang-кластер и og:url следуют за canonical.
|
|
70
|
+
*/
|
|
71
|
+
canonical?: MetaValue<string | undefined>;
|
|
61
72
|
};
|
|
62
73
|
|
|
63
74
|
export type ResolvedSeo = {
|
|
@@ -69,6 +80,7 @@ export type ResolvedSeo = {
|
|
|
69
80
|
breadcrumbs?: PageMetaBreadcrumb[];
|
|
70
81
|
status?: number;
|
|
71
82
|
redirect?: PageMetaRedirect;
|
|
83
|
+
canonical?: string;
|
|
72
84
|
};
|
|
73
85
|
|
|
74
86
|
export function createPageMeta(meta: PageMeta): PageMeta {
|
|
@@ -101,6 +113,7 @@ export async function resolveMeta(
|
|
|
101
113
|
breadcrumbs,
|
|
102
114
|
status,
|
|
103
115
|
redirect,
|
|
116
|
+
canonical,
|
|
104
117
|
] = await Promise.all([
|
|
105
118
|
resolveValue(meta.title, ctx),
|
|
106
119
|
resolveValue(meta.description, ctx),
|
|
@@ -110,6 +123,7 @@ export async function resolveMeta(
|
|
|
110
123
|
resolveValue(meta.breadcrumbs, ctx),
|
|
111
124
|
resolveValue(meta.status, ctx),
|
|
112
125
|
resolveValue(meta.redirect, ctx),
|
|
126
|
+
resolveValue(meta.canonical, ctx),
|
|
113
127
|
]);
|
|
114
128
|
return {
|
|
115
129
|
title,
|
|
@@ -120,6 +134,7 @@ export async function resolveMeta(
|
|
|
120
134
|
breadcrumbs,
|
|
121
135
|
status,
|
|
122
136
|
redirect,
|
|
137
|
+
canonical,
|
|
123
138
|
};
|
|
124
139
|
}
|
|
125
140
|
|
|
@@ -3,6 +3,7 @@ import { Request, Response } from "express";
|
|
|
3
3
|
import { matchPath } from "react-router-dom";
|
|
4
4
|
import { UglyModule } from "../Router/UglyModule.js";
|
|
5
5
|
import removeSearchParamsFromUrl from "../utils/removeSearchParamsFromUrl.js";
|
|
6
|
+
import { parseSearch } from "../utils/parseSearch.js";
|
|
6
7
|
import { resolveMeta, type PageMeta } from "../seo/createPageMeta.js";
|
|
7
8
|
import {
|
|
8
9
|
buildLocalePath,
|
|
@@ -62,12 +63,16 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
62
63
|
return;
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
// query нужен мете (пагинация) и ключу SEO-карты: одна и та же
|
|
67
|
+
// страница с разными ?page — разные title/canonical
|
|
68
|
+
const { search, query } = parseSearch(req.originalUrl);
|
|
65
69
|
const renderer = new rendererModule.Renderer({
|
|
66
70
|
url: req.originalUrl,
|
|
67
71
|
manifest,
|
|
68
72
|
locale,
|
|
69
73
|
basename,
|
|
70
74
|
strippedPathname,
|
|
75
|
+
search,
|
|
71
76
|
});
|
|
72
77
|
|
|
73
78
|
const page = rendererModule.hasUglyPage(strippedPathname) as
|
|
@@ -108,6 +113,7 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
108
113
|
params: (routeProps?.params ?? {}) as Record<string, string>,
|
|
109
114
|
locale: locale || undefined,
|
|
110
115
|
origin,
|
|
116
|
+
query,
|
|
111
117
|
});
|
|
112
118
|
|
|
113
119
|
// редирект из меты: страница переехала или схлопнута — отвечаем
|
|
@@ -122,13 +128,21 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
122
128
|
|
|
123
129
|
res.status(seo.status ?? (page ? 200 : 404));
|
|
124
130
|
|
|
125
|
-
// hreflang-кластер: каждая локаль + x-default на
|
|
131
|
+
// hreflang-кластер: каждая локаль + x-default на дефолтную.
|
|
132
|
+
// Относительный canonical (например /tbilisi?page=2) задаёт путь
|
|
133
|
+
// кластера — иначе страница 2 ссылалась бы на страницу 1 других
|
|
134
|
+
// локалей и кластер переставал быть взаимным
|
|
135
|
+
const alternatePath =
|
|
136
|
+
seo.canonical && seo.canonical.startsWith("/")
|
|
137
|
+
? seo.canonical
|
|
138
|
+
: pathname;
|
|
126
139
|
const alternates =
|
|
127
140
|
i18nConfig && usesLocalePrefix(i18nConfig)
|
|
128
141
|
? [
|
|
129
142
|
...i18nConfig.locales.map((l) => ({
|
|
130
143
|
hreflang: l,
|
|
131
|
-
href:
|
|
144
|
+
href:
|
|
145
|
+
origin + buildLocalePath(l, alternatePath),
|
|
132
146
|
})),
|
|
133
147
|
{
|
|
134
148
|
hreflang: "x-default",
|
|
@@ -136,7 +150,7 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
136
150
|
origin +
|
|
137
151
|
buildLocalePath(
|
|
138
152
|
i18nConfig.defaultLocale,
|
|
139
|
-
|
|
153
|
+
alternatePath,
|
|
140
154
|
),
|
|
141
155
|
},
|
|
142
156
|
]
|
|
@@ -148,7 +162,8 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
148
162
|
store,
|
|
149
163
|
trpcClient,
|
|
150
164
|
seo,
|
|
151
|
-
|
|
165
|
+
// ключ — путь + search, как его увидит SeoProvider через useLocation()
|
|
166
|
+
seoObject: { [pathname + search]: seo },
|
|
152
167
|
favicon: reactAtoms?.favicon?.value,
|
|
153
168
|
origin,
|
|
154
169
|
alternates,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { parseSearch, seoKey } from "./parseSearch.js";
|
|
3
|
+
|
|
4
|
+
test("parseSearch: первое значение параметра, нормализованный search", () => {
|
|
5
|
+
expect(parseSearch("/ru/tbilisi?page=2&rooms=3&rooms=4#top")).toEqual({
|
|
6
|
+
search: "?page=2&rooms=3&rooms=4",
|
|
7
|
+
query: { page: "2", rooms: "3" },
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("parseSearch: пустой и одиночный ? дают пустой search", () => {
|
|
12
|
+
expect(parseSearch("/ru/tbilisi")).toEqual({ search: "", query: {} });
|
|
13
|
+
expect(parseSearch("?")).toEqual({ search: "", query: {} });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("seoKey совпадает для SSR-URL и useLocation()", () => {
|
|
17
|
+
expect(seoKey("/tbilisi", "?page=2")).toBe("/tbilisi?page=2");
|
|
18
|
+
expect(seoKey("/tbilisi", "")).toBe("/tbilisi");
|
|
19
|
+
expect(seoKey("/tbilisi", "?")).toBe("/tbilisi");
|
|
20
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Разбор query-строки URL для меты и ключа SEO-карты. Без Node-зависимостей:
|
|
2
|
+
// модуль нужен и SSR-мидлваре, и SeoProvider в браузере.
|
|
3
|
+
|
|
4
|
+
export type ParsedSearch = {
|
|
5
|
+
/** нормализованный search: "?a=1" или "" (одиночный "?" — тоже "") */
|
|
6
|
+
search: string;
|
|
7
|
+
/** первое значение каждого параметра */
|
|
8
|
+
query: Record<string, string>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function parseSearch(urlOrSearch: string): ParsedSearch {
|
|
12
|
+
const index = urlOrSearch.indexOf("?");
|
|
13
|
+
const raw = index === -1 ? "" : urlOrSearch.slice(index + 1);
|
|
14
|
+
// hash в URL не относится к запросу
|
|
15
|
+
const withoutHash = raw.split("#")[0] ?? "";
|
|
16
|
+
const params = new URLSearchParams(withoutHash);
|
|
17
|
+
const query: Record<string, string> = {};
|
|
18
|
+
for (const [key, value] of params) {
|
|
19
|
+
if (!(key in query)) query[key] = value;
|
|
20
|
+
}
|
|
21
|
+
const search = params.toString();
|
|
22
|
+
return { search: search ? `?${search}` : "", query };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Ключ SEO-карты: путь без basename + нормализованный search. */
|
|
26
|
+
export const seoKey = (pathname: string, search: string) =>
|
|
27
|
+
pathname + parseSearch(search).search;
|