@quark-fw/plugin-react 0.1.16 → 0.1.18
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/Router/PageComponentRenderer.tsx +9 -2
- package/src/Router/UglyModuleRenderer.tsx +11 -0
- package/src/frontend/HTML/index.tsx +17 -1
- package/src/frontend/entry-server.tsx +1 -0
- package/src/seo/BreadCrumbs.tsx +28 -0
- package/src/seo/createPageMeta.test.ts +30 -1
- package/src/seo/createPageMeta.ts +36 -9
- package/src/ssr/createPreloadTagWriter.ts +39 -22
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.18",
|
|
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.18",
|
|
26
|
+
"@quark-fw/plugin": "0.1.18",
|
|
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.18",
|
|
40
|
+
"@quark-fw/store": "0.1.18",
|
|
41
41
|
"@vitejs/plugin-react": "^5.1.0",
|
|
42
42
|
"react": "^19.2.4",
|
|
43
43
|
"react-dom": "^19.2.4",
|
|
@@ -3,19 +3,26 @@ import { memo, ReactNode, type ComponentType } from "react";
|
|
|
3
3
|
function PageComponentRenderer({
|
|
4
4
|
children,
|
|
5
5
|
Component,
|
|
6
|
+
// служебный ключ сравнения — странице не нужен
|
|
7
|
+
breadCrumbsKey: _breadCrumbsKey,
|
|
6
8
|
...restProps
|
|
7
9
|
}: {
|
|
8
10
|
children?: ReactNode;
|
|
9
11
|
Component: ComponentType<Record<string, unknown>>;
|
|
10
|
-
|
|
12
|
+
breadCrumbsKey?: string;
|
|
13
|
+
// остальное — пропсы страницы, их состав задаёт роутер (UglyPageProps)
|
|
11
14
|
[key: string]: unknown;
|
|
12
15
|
}) {
|
|
13
16
|
return <Component {...restProps}>{children}</Component>;
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
// Крошки сравниваются по ключу, а не по элементу: элемент новый на каждый
|
|
20
|
+
// рендер, и сравнение по нему отключило бы мемоизацию целиком; без ключа
|
|
21
|
+
// крошки, пришедшие после клиентского резолва меты, не дорисовались бы.
|
|
16
22
|
export const MemoPageComponentRenderer = memo(
|
|
17
23
|
PageComponentRenderer,
|
|
18
24
|
(prevProps, nextProps) =>
|
|
19
25
|
prevProps.pathname === nextProps.pathname &&
|
|
20
|
-
JSON.stringify(prevProps.params) === JSON.stringify(nextProps.params)
|
|
26
|
+
JSON.stringify(prevProps.params) === JSON.stringify(nextProps.params) &&
|
|
27
|
+
prevProps.breadCrumbsKey === nextProps.breadCrumbsKey,
|
|
21
28
|
);
|
|
@@ -2,6 +2,8 @@ import { memo, ReactNode, Suspense } from "react";
|
|
|
2
2
|
import { matchPath } from "react-router-dom";
|
|
3
3
|
import { Loader } from "@mantine/core";
|
|
4
4
|
import { ErrorHandler } from "../frontend/ErrorHandler.js";
|
|
5
|
+
import { useSeo } from "../seo/SeoContext.js";
|
|
6
|
+
import { BreadCrumbs } from "../seo/BreadCrumbs.js";
|
|
5
7
|
import { MemoPageComponentRenderer } from "./PageComponentRenderer.js";
|
|
6
8
|
import { UglyModule } from "./UglyModule.js";
|
|
7
9
|
import type { ViteModulesEager } from "./types.js";
|
|
@@ -20,6 +22,9 @@ function UglyModuleRenderer({
|
|
|
20
22
|
children: ReactNode;
|
|
21
23
|
}) {
|
|
22
24
|
const uglyModule = renderPath[depth]!;
|
|
25
|
+
// крошки из меты текущей страницы (на SSR — сразу, на клиенте — после
|
|
26
|
+
// резолва меты в SeoProvider)
|
|
27
|
+
const { breadcrumbs } = useSeo();
|
|
23
28
|
|
|
24
29
|
if (!uglyModule) return children;
|
|
25
30
|
const PageComponent = uglyModule?.getWrappedPromise()?.default;
|
|
@@ -44,6 +49,12 @@ function UglyModuleRenderer({
|
|
|
44
49
|
Component={PageComponent}
|
|
45
50
|
pathname={pathname}
|
|
46
51
|
params={routeProps?.params}
|
|
52
|
+
breadCrumbs={
|
|
53
|
+
breadcrumbs?.length ? (
|
|
54
|
+
<BreadCrumbs items={breadcrumbs} />
|
|
55
|
+
) : null
|
|
56
|
+
}
|
|
57
|
+
breadCrumbsKey={JSON.stringify(breadcrumbs ?? null)}
|
|
47
58
|
>
|
|
48
59
|
<ErrorHandler>
|
|
49
60
|
<Suspense fallback={<Loader />}>
|
|
@@ -3,7 +3,10 @@ import { ColorSchemeScript } from "@mantine/core";
|
|
|
3
3
|
import { Manifest } from "vite";
|
|
4
4
|
import removeSearchParamsFromUrl from "../../utils/removeSearchParamsFromUrl.js";
|
|
5
5
|
import serializeForScript from "../../utils/serializeForScript.js";
|
|
6
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
breadcrumbsJsonLd,
|
|
8
|
+
type ResolvedSeo,
|
|
9
|
+
} from "../../seo/createPageMeta.js";
|
|
7
10
|
|
|
8
11
|
export type HTMLAlternate = { hreflang: string; href: string };
|
|
9
12
|
|
|
@@ -21,6 +24,8 @@ export type HTMLProps = {
|
|
|
21
24
|
origin?: string;
|
|
22
25
|
/** hreflang-кластер страницы (приложения с префиксами локалей) */
|
|
23
26
|
alternates?: HTMLAlternate[];
|
|
27
|
+
/** origin + basename роутера — абсолютные @id в BreadcrumbList */
|
|
28
|
+
linkBase?: string;
|
|
24
29
|
};
|
|
25
30
|
|
|
26
31
|
function HTML({
|
|
@@ -32,6 +37,7 @@ function HTML({
|
|
|
32
37
|
favicon,
|
|
33
38
|
origin,
|
|
34
39
|
alternates,
|
|
40
|
+
linkBase,
|
|
35
41
|
}: HTMLProps) {
|
|
36
42
|
const path = removeSearchParamsFromUrl(pathname!);
|
|
37
43
|
// без origin остаётся относительный URL — как было до появления поля
|
|
@@ -93,6 +99,16 @@ function HTML({
|
|
|
93
99
|
) : null}
|
|
94
100
|
</>
|
|
95
101
|
) : null}
|
|
102
|
+
{seo?.breadcrumbs?.length ? (
|
|
103
|
+
<script
|
|
104
|
+
type="application/ld+json"
|
|
105
|
+
dangerouslySetInnerHTML={{
|
|
106
|
+
__html: serializeForScript(
|
|
107
|
+
breadcrumbsJsonLd(seo.breadcrumbs, linkBase),
|
|
108
|
+
),
|
|
109
|
+
}}
|
|
110
|
+
/>
|
|
111
|
+
) : null}
|
|
96
112
|
{seo?.jsonLD?.map((node, index) => (
|
|
97
113
|
<script
|
|
98
114
|
key={index}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Anchor, Breadcrumbs } from "@mantine/core";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import type { PageMetaBreadcrumb } from "./createPageMeta.js";
|
|
4
|
+
|
|
5
|
+
/** Крошки из меты страницы — роутер передаёт их компоненту пропсом breadCrumbs. */
|
|
6
|
+
export function BreadCrumbs({ items }: { items: PageMetaBreadcrumb[] }) {
|
|
7
|
+
return (
|
|
8
|
+
<Breadcrumbs styles={{ root: { flexWrap: "wrap", rowGap: 4 } }}>
|
|
9
|
+
{items.map(({ label, to }, index) => (
|
|
10
|
+
<Anchor
|
|
11
|
+
key={index}
|
|
12
|
+
component={Link}
|
|
13
|
+
to={to}
|
|
14
|
+
// длинные хвостовые подписи (имена объявлений) не должны
|
|
15
|
+
// разваливать узкие экраны
|
|
16
|
+
style={{
|
|
17
|
+
maxWidth: 240,
|
|
18
|
+
overflow: "hidden",
|
|
19
|
+
textOverflow: "ellipsis",
|
|
20
|
+
whiteSpace: "nowrap",
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
{label}
|
|
24
|
+
</Anchor>
|
|
25
|
+
))}
|
|
26
|
+
</Breadcrumbs>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test } from "vitest";
|
|
2
|
-
import { resolveMeta } from "./createPageMeta.js";
|
|
2
|
+
import { breadcrumbsJsonLd, resolveMeta } from "./createPageMeta.js";
|
|
3
3
|
|
|
4
4
|
test("resolveMeta передаёт locale и origin в функции меты и резолвит jsonLD", async () => {
|
|
5
5
|
const ctx = {
|
|
@@ -22,3 +22,32 @@ test("resolveMeta передаёт locale и origin в функции меты
|
|
|
22
22
|
]);
|
|
23
23
|
expect(seo.og?.locale).toBe("ru_RU");
|
|
24
24
|
});
|
|
25
|
+
|
|
26
|
+
test("breadcrumbsJsonLd строит BreadcrumbList с абсолютными @id", () => {
|
|
27
|
+
const node = breadcrumbsJsonLd(
|
|
28
|
+
[
|
|
29
|
+
{ label: "Главная", to: "/" },
|
|
30
|
+
{ label: "Тбилиси", to: "/tbilisi" },
|
|
31
|
+
],
|
|
32
|
+
"https://bina.rent/ru",
|
|
33
|
+
);
|
|
34
|
+
expect(node).toEqual({
|
|
35
|
+
"@context": "https://schema.org",
|
|
36
|
+
"@type": "BreadcrumbList",
|
|
37
|
+
itemListElement: [
|
|
38
|
+
{
|
|
39
|
+
"@type": "ListItem",
|
|
40
|
+
position: 1,
|
|
41
|
+
item: { "@id": "https://bina.rent/ru/", name: "Главная" },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"@type": "ListItem",
|
|
45
|
+
position: 2,
|
|
46
|
+
item: {
|
|
47
|
+
"@id": "https://bina.rent/ru/tbilisi",
|
|
48
|
+
name: "Тбилиси",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -31,6 +31,9 @@ export type PageMetaOG = {
|
|
|
31
31
|
/** узел JSON-LD; форма задаётся schema.org, здесь — только объект */
|
|
32
32
|
export type JsonLd = Record<string, unknown>;
|
|
33
33
|
|
|
34
|
+
/** крошка навигации: подпись и путь относительно basename роутера */
|
|
35
|
+
export type PageMetaBreadcrumb = { label: string; to: string };
|
|
36
|
+
|
|
34
37
|
export type PageMeta = {
|
|
35
38
|
title?: MetaValue<string>;
|
|
36
39
|
description?: MetaValue<string>;
|
|
@@ -39,6 +42,11 @@ export type PageMeta = {
|
|
|
39
42
|
og?: MetaValue<PageMetaOG | undefined>;
|
|
40
43
|
/** структурированные данные: по <script type="application/ld+json"> на узел */
|
|
41
44
|
jsonLD?: MetaValue<JsonLd[] | undefined>;
|
|
45
|
+
/**
|
|
46
|
+
* Крошки страницы: роутер отдаёт их компоненту пропсом breadCrumbs
|
|
47
|
+
* (готовая разметка), а <head> — узлом BreadcrumbList JSON-LD.
|
|
48
|
+
*/
|
|
49
|
+
breadcrumbs?: MetaValue<PageMetaBreadcrumb[] | undefined>;
|
|
42
50
|
/** HTTP-статус ответа (например 404 для «не найдено») */
|
|
43
51
|
status?: MetaValue<number>;
|
|
44
52
|
};
|
|
@@ -49,6 +57,7 @@ export type ResolvedSeo = {
|
|
|
49
57
|
robots?: string;
|
|
50
58
|
og?: PageMetaOG;
|
|
51
59
|
jsonLD?: JsonLd[];
|
|
60
|
+
breadcrumbs?: PageMetaBreadcrumb[];
|
|
52
61
|
status?: number;
|
|
53
62
|
};
|
|
54
63
|
|
|
@@ -73,13 +82,31 @@ export async function resolveMeta(
|
|
|
73
82
|
ctx: PageMetaContext,
|
|
74
83
|
): Promise<ResolvedSeo> {
|
|
75
84
|
if (!meta) return {};
|
|
76
|
-
const [title, description, robots, og, jsonLD, status] =
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
const [title, description, robots, og, jsonLD, breadcrumbs, status] =
|
|
86
|
+
await Promise.all([
|
|
87
|
+
resolveValue(meta.title, ctx),
|
|
88
|
+
resolveValue(meta.description, ctx),
|
|
89
|
+
resolveValue(meta.robots, ctx),
|
|
90
|
+
resolveValue(meta.og, ctx),
|
|
91
|
+
resolveValue(meta.jsonLD, ctx),
|
|
92
|
+
resolveValue(meta.breadcrumbs, ctx),
|
|
93
|
+
resolveValue(meta.status, ctx),
|
|
94
|
+
]);
|
|
95
|
+
return { title, description, robots, og, jsonLD, breadcrumbs, status };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Узел BreadcrumbList для <head>; linkBase — origin + basename роутера. */
|
|
99
|
+
export function breadcrumbsJsonLd(
|
|
100
|
+
breadcrumbs: PageMetaBreadcrumb[],
|
|
101
|
+
linkBase = "",
|
|
102
|
+
): JsonLd {
|
|
103
|
+
return {
|
|
104
|
+
"@context": "https://schema.org",
|
|
105
|
+
"@type": "BreadcrumbList",
|
|
106
|
+
itemListElement: breadcrumbs.map(({ label, to }, index) => ({
|
|
107
|
+
"@type": "ListItem",
|
|
108
|
+
position: index + 1,
|
|
109
|
+
item: { "@id": linkBase + to, name: label },
|
|
110
|
+
})),
|
|
111
|
+
};
|
|
85
112
|
}
|
|
@@ -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
|
}
|