@quark-fw/plugin-react 0.1.23 → 0.1.25

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.23",
3
+ "version": "0.1.25",
4
4
  "main": "src/index.ts",
5
5
  "types": "src/index.ts",
6
6
  "type": "module",
@@ -22,9 +22,9 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@quark-fw/atom": "0.1.23",
26
- "@quark-fw/plugin": "0.1.23",
27
- "@trpc/client": "^11.0.0",
25
+ "@quark-fw/atom": "0.1.25",
26
+ "@quark-fw/plugin": "0.1.25",
27
+ "@trpc/client": "~11.18.0",
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "express": "^5.2.1",
30
30
  "vite-css-modules": "^1.13.0",
@@ -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.23",
40
- "@quark-fw/store": "0.1.23",
39
+ "@quark-fw/core": "0.1.25",
40
+ "@quark-fw/store": "0.1.25",
41
41
  "@vitejs/plugin-react": "^5.1.0",
42
42
  "react": "^19.2.4",
43
43
  "react-dom": "^19.2.4",
@@ -45,6 +45,7 @@
45
45
  "vite": "^7.1.0"
46
46
  },
47
47
  "devDependencies": {
48
+ "@trpc/server": "~11.18.0",
48
49
  "@types/express": "^5.0.6",
49
50
  "@types/react": "^19.2.14",
50
51
  "vitest": "^5.0.0"
@@ -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;
@@ -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
 
@@ -0,0 +1,167 @@
1
+ import { afterAll, beforeAll, expect, test } from "vitest";
2
+ import express from "express";
3
+ import http, { type Server } from "node:http";
4
+ import type { AddressInfo } from "node:net";
5
+ import type { Request, Response } from "express";
6
+ import { initTRPC, TRPCError } from "@trpc/server";
7
+ import { createExpressMiddleware } from "@trpc/server/adapters/express";
8
+ import { isTRPCClientError } from "@trpc/client";
9
+ import { createRequestTrpcClient } from "./createRequestTrpcClient.js";
10
+
11
+ type Ctx = { req: Request; res: Response; user?: { id: string } };
12
+ const t = initTRPC.context<Ctx>().create();
13
+
14
+ // счётчик HTTP-вызовов: в local-режиме сервер не должен получать запросов
15
+ let httpHits = 0;
16
+
17
+ const appRouter = t.router({
18
+ entity: t.router({
19
+ task: t.router({
20
+ list: t.procedure
21
+ .input((v: unknown) => v as { limit: number })
22
+ .mutation(({ input, ctx }) => ({
23
+ limit: input.limit,
24
+ cookie: ctx.req.headers.cookie ?? null,
25
+ user: ctx.user?.id ?? null,
26
+ })),
27
+ }),
28
+ }),
29
+ secret: t.procedure.query(() => {
30
+ throw new TRPCError({ code: "UNAUTHORIZED", message: "login first" });
31
+ }),
32
+ now: t.procedure.query(() => new Date("2026-10-07T00:00:00.000Z")),
33
+ });
34
+
35
+ const createContext = ({ req, res }: { req: Request; res: Response }): Ctx => ({
36
+ req,
37
+ res,
38
+ user: (req as { user?: { id: string } }).user,
39
+ });
40
+
41
+ let server: Server;
42
+ let port: number;
43
+
44
+ beforeAll(async () => {
45
+ const app = express();
46
+ app.use((_req, _res, next) => {
47
+ httpHits++;
48
+ next();
49
+ });
50
+ app.use(
51
+ "/api/trpc",
52
+ createExpressMiddleware({ router: appRouter, createContext }),
53
+ );
54
+ server = http.createServer(app);
55
+ await new Promise<void>((resolve) => server.listen(0, resolve));
56
+ port = (server.address() as AddressInfo).port;
57
+ });
58
+
59
+ afterAll(async () => {
60
+ await new Promise<void>((resolve) => server.close(() => resolve()));
61
+ });
62
+
63
+ // SSR-запрос: cookie браузера и user, положенный passport'ом
64
+ const fakeReq = () =>
65
+ ({
66
+ headers: { cookie: "quark.sid=abc" },
67
+ user: { id: "u1" },
68
+ }) as unknown as Request;
69
+ const fakeRes = () => ({}) as unknown as Response;
70
+
71
+ test("local: вложенная mutation вызывается без HTTP, с контекстом запроса", async () => {
72
+ const before = httpHits;
73
+ const client = createRequestTrpcClient({
74
+ req: fakeReq(),
75
+ res: fakeRes(),
76
+ port,
77
+ router: appRouter,
78
+ createContext,
79
+ });
80
+ const result = await (
81
+ client as unknown as {
82
+ entity: {
83
+ task: {
84
+ list: {
85
+ mutate: (i: { limit: number }) => Promise<unknown>;
86
+ };
87
+ };
88
+ };
89
+ }
90
+ ).entity.task.list.mutate({ limit: 5 });
91
+ expect(result).toEqual({ limit: 5, cookie: "quark.sid=abc", user: "u1" });
92
+ expect(httpHits).toBe(before);
93
+ });
94
+
95
+ test("local: TRPCError доходит как TRPCClientError с кодом, как по HTTP", async () => {
96
+ const client = createRequestTrpcClient({
97
+ req: fakeReq(),
98
+ res: fakeRes(),
99
+ port,
100
+ router: appRouter,
101
+ createContext,
102
+ });
103
+ const call = (
104
+ client as unknown as { secret: { query: () => Promise<unknown> } }
105
+ ).secret.query();
106
+ await expect(call).rejects.toSatisfy((e: unknown) => {
107
+ return (
108
+ isTRPCClientError(e) &&
109
+ e.data?.code === "UNAUTHORIZED" &&
110
+ e.message === "login first"
111
+ );
112
+ });
113
+ });
114
+
115
+ test("local: Date приходит строкой — та же форма, что после JSON по HTTP", async () => {
116
+ const client = createRequestTrpcClient({
117
+ req: fakeReq(),
118
+ res: fakeRes(),
119
+ port,
120
+ router: appRouter,
121
+ createContext,
122
+ });
123
+ const now = await (
124
+ client as unknown as { now: { query: () => Promise<unknown> } }
125
+ ).now.query();
126
+ expect(now).toBe("2026-10-07T00:00:00.000Z");
127
+ });
128
+
129
+ test("http: без роутера идём на loopback и пробрасываем cookie", async () => {
130
+ const before = httpHits;
131
+ const client = createRequestTrpcClient({
132
+ req: fakeReq(),
133
+ res: fakeRes(),
134
+ port,
135
+ });
136
+ const result = await (
137
+ client as unknown as {
138
+ entity: {
139
+ task: {
140
+ list: {
141
+ mutate: (i: { limit: number }) => Promise<unknown>;
142
+ };
143
+ };
144
+ };
145
+ }
146
+ ).entity.task.list.mutate({ limit: 1 });
147
+ // user по HTTP не передаётся: его на той стороне восстановит сессия
148
+ expect(result).toEqual({ limit: 1, cookie: "quark.sid=abc", user: null });
149
+ expect(httpHits).toBe(before + 1);
150
+ });
151
+
152
+ test("http: явный url главнее локального роутера (API в другом процессе)", async () => {
153
+ const before = httpHits;
154
+ const client = createRequestTrpcClient({
155
+ req: fakeReq(),
156
+ res: fakeRes(),
157
+ port: 1, // заведомо мёртвый loopback-порт: должен использоваться url
158
+ router: appRouter,
159
+ createContext,
160
+ url: `http://127.0.0.1:${port}/api/trpc`,
161
+ });
162
+ const now = await (
163
+ client as unknown as { now: { query: () => Promise<unknown> } }
164
+ ).now.query();
165
+ expect(now).toBe("2026-10-07T00:00:00.000Z");
166
+ expect(httpHits).toBe(before + 1);
167
+ });
@@ -1,20 +1,75 @@
1
- import { createTRPCClient, httpLink } from "@trpc/client";
1
+ import { createTRPCClient, httpLink, unstable_localLink } from "@trpc/client";
2
+ import type { Request, Response } from "express";
2
3
  import { jsonSafeFetch, type TRPCClient } from "@quark-fw/store";
3
4
 
5
+ // Тип роутера берём из сигнатуры link'а: plugin-react не зависит от
6
+ // @trpc/server и не должен тянуть его ради одного типа
7
+ type LocalLinkOptions = Parameters<typeof unstable_localLink>[0];
8
+ export type SsrRouter = LocalLinkOptions["router"];
9
+
10
+ export type CreateRequestTrpcClientArgs = {
11
+ req: Request;
12
+ res: Response;
13
+ /** порт собственного HTTP-сервера — для loopback, когда роутера нет */
14
+ port: number;
15
+ /** роутер приложения, если он живёт в этом же процессе (quark.trpc.router) */
16
+ router?: SsrRouter;
17
+ /** контекст процедуры из запроса — тот же, что у /api/trpc */
18
+ createContext?: (args: { req: Request; res: Response }) => unknown;
19
+ /**
20
+ * Явный адрес API (SSR_TRPC_URL): tRPC-сервер в другом процессе или на
21
+ * другой машине. Главнее локального роутера.
22
+ */
23
+ url?: string;
24
+ };
25
+
4
26
  /**
5
- * tRPC-клиент КОНКРЕТНОГО запроса: SSR ходит к собственному серверу по
6
- * HTTP и пробрасывает cookie, чтобы страница рендерилась от имени
7
- * залогиненного пользователя. Клиент собирается здесь, а роутер объявляет
27
+ * tRPC-клиент КОНКРЕТНОГО SSR-запроса. Три транспорта, по убыванию приоритета:
28
+ *
29
+ * 1. `url` — HTTP к внешнему API (другой процесс/машина), cookie пробрасывается.
30
+ * 2. `router` — вызов процедур in-process: без HTTP, сессии и passport на
31
+ * каждое чтение store; контекст собирается из этого же req/res, так что
32
+ * процедуры видят того же пользователя, что и по HTTP. Результат проходит
33
+ * JSON-раундтрип внутри link'а — Date приходит строкой, как по сети.
34
+ * 3. loopback — HTTP к собственному серверу (поведение до появления router).
35
+ *
36
+ * Ограничение in-process пути: ctx.res — это стримящийся SSR-ответ, и
37
+ * процедура, которая ставит заголовки, после записи shell получит
38
+ * ERR_HTTP_HEADERS_SENT. Клиент собирается здесь, а роутер объявляет
8
39
  * приложение — тот же шов аугментации, что и в сторе.
9
40
  */
10
- export function createRequestTrpcClient(
11
- port: number,
12
- cookie: string | undefined,
13
- ): TRPCClient {
41
+ export function createRequestTrpcClient({
42
+ req,
43
+ res,
44
+ port,
45
+ router,
46
+ createContext,
47
+ url,
48
+ }: CreateRequestTrpcClientArgs): TRPCClient {
49
+ const cookie = req.headers.cookie;
50
+
51
+ if (!url && router && createContext) {
52
+ return createTRPCClient({
53
+ links: [
54
+ unstable_localLink({
55
+ router,
56
+ createContext: async () => createContext({ req, res }),
57
+ // как express-адаптер: наружу уходит форма ошибки, в лог —
58
+ // только то, что по HTTP было бы 500
59
+ onError: ({ error, path }) => {
60
+ if (error.code === "INTERNAL_SERVER_ERROR") {
61
+ console.error(`[react] ssr tRPC ${path}:`, error);
62
+ }
63
+ },
64
+ }),
65
+ ],
66
+ }) as TRPCClient;
67
+ }
68
+
14
69
  return createTRPCClient({
15
70
  links: [
16
71
  httpLink({
17
- url: `http://127.0.0.1:${port}/api/trpc`,
72
+ url: url ?? `http://127.0.0.1:${port}/api/trpc`,
18
73
  headers: cookie ? { cookie } : {},
19
74
  fetch: jsonSafeFetch,
20
75
  }),
@@ -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,
@@ -14,6 +15,7 @@ import {
14
15
  import {
15
16
  getReactAtoms,
16
17
  getRequestOrigin,
18
+ getTrpcPlugin,
17
19
  parseCookies,
18
20
  serverErrorBody,
19
21
  } from "./helpers.js";
@@ -62,12 +64,16 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
62
64
  return;
63
65
  }
64
66
 
67
+ // query нужен мете (пагинация) и ключу SEO-карты: одна и та же
68
+ // страница с разными ?page — разные title/canonical
69
+ const { search, query } = parseSearch(req.originalUrl);
65
70
  const renderer = new rendererModule.Renderer({
66
71
  url: req.originalUrl,
67
72
  manifest,
68
73
  locale,
69
74
  basename,
70
75
  strippedPathname,
76
+ search,
71
77
  });
72
78
 
73
79
  const page = rendererModule.hasUglyPage(strippedPathname) as
@@ -76,10 +82,17 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
76
82
  const registerModule = createPreloadTagWriter(manifest, res);
77
83
 
78
84
  const reactAtoms = getReactAtoms();
79
- const trpcClient = createRequestTrpcClient(
80
- reactAtoms?.port?.value ?? 3000,
81
- req.headers.cookie,
82
- );
85
+ // роутер читается на каждый запрос: приложение отдаёт его в
86
+ // setRouter после quark.ready(), конкурентно со стартом сервера
87
+ const trpc = getTrpcPlugin();
88
+ const trpcClient = createRequestTrpcClient({
89
+ req,
90
+ res,
91
+ port: reactAtoms?.port?.value ?? 3000,
92
+ router: trpc?.router,
93
+ createContext: trpc?.createContext,
94
+ url: process.env.SSR_TRPC_URL,
95
+ });
83
96
  const { store, releaseCacheWrites } = createStreamingStore(
84
97
  trpcClient,
85
98
  res,
@@ -108,6 +121,7 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
108
121
  params: (routeProps?.params ?? {}) as Record<string, string>,
109
122
  locale: locale || undefined,
110
123
  origin,
124
+ query,
111
125
  });
112
126
 
113
127
  // редирект из меты: страница переехала или схлопнута — отвечаем
@@ -122,13 +136,21 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
122
136
 
123
137
  res.status(seo.status ?? (page ? 200 : 404));
124
138
 
125
- // hreflang-кластер: каждая локаль + x-default на дефолтную
139
+ // hreflang-кластер: каждая локаль + x-default на дефолтную.
140
+ // Относительный canonical (например /tbilisi?page=2) задаёт путь
141
+ // кластера — иначе страница 2 ссылалась бы на страницу 1 других
142
+ // локалей и кластер переставал быть взаимным
143
+ const alternatePath =
144
+ seo.canonical && seo.canonical.startsWith("/")
145
+ ? seo.canonical
146
+ : pathname;
126
147
  const alternates =
127
148
  i18nConfig && usesLocalePrefix(i18nConfig)
128
149
  ? [
129
150
  ...i18nConfig.locales.map((l) => ({
130
151
  hreflang: l,
131
- href: origin + buildLocalePath(l, pathname),
152
+ href:
153
+ origin + buildLocalePath(l, alternatePath),
132
154
  })),
133
155
  {
134
156
  hreflang: "x-default",
@@ -136,7 +158,7 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
136
158
  origin +
137
159
  buildLocalePath(
138
160
  i18nConfig.defaultLocale,
139
- pathname,
161
+ alternatePath,
140
162
  ),
141
163
  },
142
164
  ]
@@ -148,7 +170,8 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
148
170
  store,
149
171
  trpcClient,
150
172
  seo,
151
- seoObject: { [pathname]: seo },
173
+ // ключ — путь + search, как его увидит SeoProvider через useLocation()
174
+ seoObject: { [pathname + search]: seo },
152
175
  favicon: reactAtoms?.favicon?.value,
153
176
  origin,
154
177
  alternates,
@@ -1,5 +1,6 @@
1
1
  // Хелперы SSR-мидлвары.
2
- import type { Request } from "express";
2
+ import type { Request, Response } from "express";
3
+ import type { SsrRouter } from "./createRequestTrpcClient.js";
3
4
 
4
5
  export const isProd = process.env.NODE_ENV === "production";
5
6
 
@@ -49,6 +50,19 @@ export type ReactAtoms = {
49
50
  export const getReactAtoms = (): ReactAtoms | undefined =>
50
51
  (globalThis as { quark?: { react?: ReactAtoms } }).quark?.react;
51
52
 
53
+ /**
54
+ * Поверхность plugin-trpc для in-process вызовов из SSR. Читается
55
+ * структурно: plugin-react не зависит от plugin-trpc и работает без него
56
+ * (тогда SSR ходит по HTTP).
57
+ */
58
+ export type TrpcPlugin = {
59
+ router?: SsrRouter;
60
+ createContext?: (args: { req: Request; res: Response }) => unknown;
61
+ };
62
+
63
+ export const getTrpcPlugin = (): TrpcPlugin | undefined =>
64
+ (globalThis as { quark?: { trpc?: TrpcPlugin } }).quark?.trpc;
65
+
52
66
  /**
53
67
  * Абсолютный origin запроса для canonical/hreflang/JSON-LD.
54
68
  *
@@ -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;