@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quark-fw/plugin-react",
3
- "version": "0.1.22",
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.22",
26
- "@quark-fw/plugin": "0.1.22",
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.22",
40
- "@quark-fw/store": "0.1.22",
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
- /** путь БЕЗ префикса локали — по нему клиентский SeoProvider ищет мету */
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
- // без origin остаётся относительный URL — как было до появления поля
44
- const canonical = origin ? origin + path : path;
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
- <meta property="og:image" content={abs(og.image)} />
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={this.strippedPathname}
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}
@@ -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
- const current = seoObject[pathname];
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, [pathname]: resolved }));
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
- }, [pathname, current]);
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: origin + buildLocalePath(l, pathname),
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
- pathname,
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
- seoObject: { [pathname]: seo },
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;