@quark-fw/plugin-react 0.1.14 → 0.1.16

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.14",
3
+ "version": "0.1.16",
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.14",
26
- "@quark-fw/plugin": "0.1.14",
25
+ "@quark-fw/atom": "0.1.16",
26
+ "@quark-fw/plugin": "0.1.16",
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.14",
40
- "@quark-fw/store": "0.1.14",
39
+ "@quark-fw/core": "0.1.16",
40
+ "@quark-fw/store": "0.1.16",
41
41
  "@vitejs/plugin-react": "^5.1.0",
42
42
  "react": "^19.2.4",
43
43
  "react-dom": "^19.2.4",
@@ -77,7 +77,10 @@ export default function App({
77
77
  <ModalsProvider>
78
78
  <TRPCContext value={trpcClient}>
79
79
  <StoreContext value={store}>
80
- <SeoProvider seoObject={seoObject}>
80
+ <SeoProvider
81
+ seoObject={seoObject}
82
+ locale={locale || i18nConfig?.defaultLocale}
83
+ >
81
84
  {i18nConfig ? (
82
85
  <I18nProvider
83
86
  locale={
@@ -5,6 +5,8 @@ import removeSearchParamsFromUrl from "../../utils/removeSearchParamsFromUrl.js"
5
5
  import serializeForScript from "../../utils/serializeForScript.js";
6
6
  import type { ResolvedSeo } from "../../seo/createPageMeta.js";
7
7
 
8
+ export type HTMLAlternate = { hreflang: string; href: string };
9
+
8
10
  export type HTMLProps = {
9
11
  children?: ReactNode;
10
12
  colorScheme: "dark" | "light" | undefined;
@@ -15,6 +17,10 @@ export type HTMLProps = {
15
17
  seo?: ResolvedSeo;
16
18
  /** иконка вкладки; приложение задаёт её атомом quark.react.favicon */
17
19
  favicon?: string;
20
+ /** абсолютный origin (https://host): делает canonical/og:url абсолютными */
21
+ origin?: string;
22
+ /** hreflang-кластер страницы (приложения с префиксами локалей) */
23
+ alternates?: HTMLAlternate[];
18
24
  };
19
25
 
20
26
  function HTML({
@@ -24,14 +30,29 @@ function HTML({
24
30
  seoPathname,
25
31
  seo,
26
32
  favicon,
33
+ origin,
34
+ alternates,
27
35
  }: HTMLProps) {
28
- const canonical = removeSearchParamsFromUrl(pathname!);
36
+ const path = removeSearchParamsFromUrl(pathname!);
37
+ // без origin остаётся относительный URL — как было до появления поля
38
+ const canonical = origin ? origin + path : path;
39
+ // относительная картинка og:image без origin бесполезна для соцсетей
40
+ const abs = (url?: string) =>
41
+ url && url.startsWith("/") && origin ? origin + url : url;
29
42
  const og = seo?.og;
30
43
  return (
31
44
  <>
32
45
  <head>
33
46
  <title>{seo?.title ?? "App"}</title>
34
47
  <link rel="canonical" href={canonical} />
48
+ {alternates?.map((alt) => (
49
+ <link
50
+ rel="alternate"
51
+ hrefLang={alt.hreflang}
52
+ href={alt.href}
53
+ key={alt.hreflang}
54
+ />
55
+ ))}
35
56
  {favicon ? <link rel="icon" href={favicon} /> : null}
36
57
  {seo?.description ? (
37
58
  <meta name="description" content={seo.description} />
@@ -59,7 +80,7 @@ function HTML({
59
80
  />
60
81
  ) : null}
61
82
  {og?.image ? (
62
- <meta property="og:image" content={og.image} />
83
+ <meta property="og:image" content={abs(og.image)} />
63
84
  ) : null}
64
85
  {og?.siteName ? (
65
86
  <meta
@@ -67,8 +88,20 @@ function HTML({
67
88
  content={og.siteName}
68
89
  />
69
90
  ) : null}
91
+ {og?.locale ? (
92
+ <meta property="og:locale" content={og.locale} />
93
+ ) : null}
70
94
  </>
71
95
  ) : null}
96
+ {seo?.jsonLD?.map((node, index) => (
97
+ <script
98
+ key={index}
99
+ type="application/ld+json"
100
+ dangerouslySetInnerHTML={{
101
+ __html: serializeForScript(node),
102
+ }}
103
+ />
104
+ ))}
72
105
  {/* мета для клиентской навигации (SeoProvider) */}
73
106
  <script
74
107
  dangerouslySetInnerHTML={{
@@ -76,7 +109,7 @@ function HTML({
76
109
  // (basename роутера уже срезан), иначе SeoProvider
77
110
  // промахнётся и перезапросит мету заново
78
111
  __html: `window.seo=${serializeForScript({
79
- [seoPathname ?? canonical]: seo ?? {},
112
+ [seoPathname ?? path]: seo ?? {},
80
113
  })}`,
81
114
  }}
82
115
  />
@@ -12,7 +12,7 @@ import {
12
12
  UglyModule,
13
13
  ViteModulesEager,
14
14
  } from "../Router/gatherPages.js";
15
- import HTML from "./HTML/index.js";
15
+ import HTML, { type HTMLAlternate } from "./HTML/index.js";
16
16
  import App from "./App.js";
17
17
  import type { ResolvedSeo } from "../seo/createPageMeta.js";
18
18
  import type { I18nConfig } from "../i18n/config.js";
@@ -51,6 +51,10 @@ export interface RendererPipeParams {
51
51
  seoObject?: Record<string, ResolvedSeo>;
52
52
  /** иконка вкладки из атома приложения */
53
53
  favicon?: string;
54
+ /** абсолютный origin запроса (см. getRequestOrigin) */
55
+ origin?: string;
56
+ /** hreflang-кластер страницы */
57
+ alternates?: HTMLAlternate[];
54
58
  }
55
59
 
56
60
  export const hasUglyPage = (url: string) => getMatchedRoute(url, uglyPages);
@@ -87,6 +91,8 @@ export class Renderer {
87
91
  req,
88
92
  seo,
89
93
  favicon,
94
+ origin,
95
+ alternates,
90
96
  }: Omit<RendererPipeParams, "options">) {
91
97
  return ReactDOMServer.renderToString(
92
98
  <HTML
@@ -96,6 +102,8 @@ export class Renderer {
96
102
  manifest={this.manifest}
97
103
  seo={seo}
98
104
  favicon={favicon}
105
+ origin={origin}
106
+ alternates={alternates}
99
107
  />,
100
108
  );
101
109
  }
@@ -9,9 +9,12 @@ import SeoContext, { type SeoContextValue, type SeoMap } from "./SeoContext.js";
9
9
  export function SeoProvider({
10
10
  children,
11
11
  seoObject: initial,
12
+ locale,
12
13
  }: {
13
14
  children: ReactNode;
14
15
  seoObject?: SeoMap;
16
+ /** активная локаль — её видят функции меты при клиентской навигации */
17
+ locale?: string;
15
18
  }) {
16
19
  const [seoObject, setSeoObject] = useState<SeoMap>(initial ?? {});
17
20
  const { pathname } = useLocation();
@@ -39,6 +42,11 @@ export function SeoProvider({
39
42
  const resolved = await resolveMeta(meta, {
40
43
  store,
41
44
  params: (routeProps?.params ?? {}) as Record<string, string>,
45
+ locale,
46
+ origin:
47
+ typeof window === "undefined"
48
+ ? undefined
49
+ : window.location.origin,
42
50
  });
43
51
  if (!cancelled) {
44
52
  setSeoObject((prev) => ({ ...prev, [pathname]: resolved }));
@@ -48,7 +56,8 @@ export function SeoProvider({
48
56
  cancelled = true;
49
57
  };
50
58
  // store живёт всё время жизни приложения и меняться не может, а его
51
- // добавление в зависимости ничего бы не изменило
59
+ // добавление в зависимости ничего бы не изменило; locale меняется
60
+ // только полной перезагрузкой (setLocale)
52
61
  // eslint-disable-next-line react-hooks/exhaustive-deps
53
62
  }, [pathname, current]);
54
63
 
@@ -0,0 +1,24 @@
1
+ import { expect, test } from "vitest";
2
+ import { resolveMeta } from "./createPageMeta.js";
3
+
4
+ test("resolveMeta передаёт locale и origin в функции меты и резолвит jsonLD", async () => {
5
+ const ctx = {
6
+ store: {} as never,
7
+ params: { city: "tbilisi" },
8
+ locale: "ru",
9
+ origin: "https://bina.rent",
10
+ };
11
+ const seo = await resolveMeta(
12
+ {
13
+ title: ({ locale, params }) => `${locale}:${params["city"]}`,
14
+ jsonLD: ({ origin }) => [{ "@type": "WebSite", url: `${origin}/` }],
15
+ og: { locale: "ru_RU" },
16
+ },
17
+ ctx,
18
+ );
19
+ expect(seo.title).toBe("ru:tbilisi");
20
+ expect(seo.jsonLD).toEqual([
21
+ { "@type": "WebSite", url: "https://bina.rent/" },
22
+ ]);
23
+ expect(seo.og?.locale).toBe("ru_RU");
24
+ });
@@ -6,6 +6,14 @@ import type { TStore } from "@quark-fw/store";
6
6
  export type PageMetaContext = {
7
7
  store: TStore;
8
8
  params: Record<string, string>;
9
+ /** активная локаль (только у приложений с /i18n.ts) */
10
+ locale?: string;
11
+ /**
12
+ * Абсолютный origin запроса (https://host) — для абсолютных URL в
13
+ * JSON-LD и og-тегах. На SSR берётся из PUBLIC_ORIGIN или заголовков,
14
+ * на клиенте — из window.location.
15
+ */
16
+ origin?: string;
9
17
  };
10
18
 
11
19
  export type MetaValue<T> = T | ((ctx: PageMetaContext) => T | Promise<T>);
@@ -16,14 +24,21 @@ export type PageMetaOG = {
16
24
  image?: string;
17
25
  type?: string;
18
26
  siteName?: string;
27
+ /** og:locale, например ru_RU */
28
+ locale?: string;
19
29
  };
20
30
 
31
+ /** узел JSON-LD; форма задаётся schema.org, здесь — только объект */
32
+ export type JsonLd = Record<string, unknown>;
33
+
21
34
  export type PageMeta = {
22
35
  title?: MetaValue<string>;
23
36
  description?: MetaValue<string>;
24
37
  /** например 'noindex, nofollow' */
25
38
  robots?: MetaValue<string>;
26
39
  og?: MetaValue<PageMetaOG | undefined>;
40
+ /** структурированные данные: по <script type="application/ld+json"> на узел */
41
+ jsonLD?: MetaValue<JsonLd[] | undefined>;
27
42
  /** HTTP-статус ответа (например 404 для «не найдено») */
28
43
  status?: MetaValue<number>;
29
44
  };
@@ -33,6 +48,7 @@ export type ResolvedSeo = {
33
48
  description?: string;
34
49
  robots?: string;
35
50
  og?: PageMetaOG;
51
+ jsonLD?: JsonLd[];
36
52
  status?: number;
37
53
  };
38
54
 
@@ -57,12 +73,13 @@ export async function resolveMeta(
57
73
  ctx: PageMetaContext,
58
74
  ): Promise<ResolvedSeo> {
59
75
  if (!meta) return {};
60
- const [title, description, robots, og, status] = await Promise.all([
76
+ const [title, description, robots, og, jsonLD, status] = await Promise.all([
61
77
  resolveValue(meta.title, ctx),
62
78
  resolveValue(meta.description, ctx),
63
79
  resolveValue(meta.robots, ctx),
64
80
  resolveValue(meta.og, ctx),
81
+ resolveValue(meta.jsonLD, ctx),
65
82
  resolveValue(meta.status, ctx),
66
83
  ]);
67
- return { title, description, robots, og, status };
84
+ return { title, description, robots, og, jsonLD, status };
68
85
  }
@@ -8,9 +8,15 @@ import {
8
8
  buildLocalePath,
9
9
  detectLocale,
10
10
  parseLocalePath,
11
+ usesLocalePrefix,
11
12
  type I18nConfig,
12
13
  } from "../i18n/config.js";
13
- import { getReactAtoms, parseCookies } from "./helpers.js";
14
+ import {
15
+ getReactAtoms,
16
+ getRequestOrigin,
17
+ parseCookies,
18
+ serverErrorBody,
19
+ } from "./helpers.js";
14
20
  import { resolveClientAssets } from "./resolveClientAssets.js";
15
21
  import { createRendererLoader } from "./createRendererLoader.js";
16
22
  import { createPreloadTagWriter } from "./createPreloadTagWriter.js";
@@ -80,8 +86,10 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
80
86
  );
81
87
 
82
88
  // мета страницы: резолвится ПЕРЕД стримом (title/description
83
- // должны попасть в <head>), функции меты видят store и params
89
+ // должны попасть в <head>), функции меты видят store, params,
90
+ // локаль и origin
84
91
  const pathname = strippedPathname;
92
+ const origin = getRequestOrigin(req);
85
93
  const pageMeta = (
86
94
  page?.module?.default as { meta?: PageMeta } | undefined
87
95
  )?.meta;
@@ -98,10 +106,32 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
98
106
  const seo = await resolveMeta(pageMeta, {
99
107
  store,
100
108
  params: (routeProps?.params ?? {}) as Record<string, string>,
109
+ locale: locale || undefined,
110
+ origin,
101
111
  });
102
112
 
103
113
  res.status(seo.status ?? (page ? 200 : 404));
104
114
 
115
+ // hreflang-кластер: каждая локаль + x-default на дефолтную
116
+ const alternates =
117
+ i18nConfig && usesLocalePrefix(i18nConfig)
118
+ ? [
119
+ ...i18nConfig.locales.map((l) => ({
120
+ hreflang: l,
121
+ href: origin + buildLocalePath(l, pathname),
122
+ })),
123
+ {
124
+ hreflang: "x-default",
125
+ href:
126
+ origin +
127
+ buildLocalePath(
128
+ i18nConfig.defaultLocale,
129
+ pathname,
130
+ ),
131
+ },
132
+ ]
133
+ : undefined;
134
+
105
135
  const data = {
106
136
  req,
107
137
  registerModule,
@@ -110,8 +140,15 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
110
140
  seo,
111
141
  seoObject: { [pathname]: seo },
112
142
  favicon: reactAtoms?.favicon?.value,
143
+ origin,
144
+ alternates,
113
145
  };
114
- const head = renderer.head(data);
146
+ // INJECT_HTML — разметка оператора (счётчики, verification), не
147
+ // пользовательский ввод; вставляется в конец <head> как есть
148
+ const injectHtml = process.env.INJECT_HTML ?? "";
149
+ const head = renderer
150
+ .head(data)
151
+ .replace(/<\/head>$/, `${injectHtml}</head>`);
115
152
  // с i18n язык берётся из закрытого списка локалей, без него —
116
153
  // из quark.react.lang (приложение задаёт его само)
117
154
  const htmlLang = locale || reactAtoms?.lang?.value || "en";
@@ -129,16 +166,14 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
129
166
  },
130
167
  onError: (error) => {
131
168
  console.error(error);
132
- res.end(
133
- `Server Error: ${error instanceof Error ? error.message : error}`,
134
- );
169
+ res.end(serverErrorBody(error, "message"));
135
170
  },
136
171
  bootstrapModules,
137
172
  },
138
173
  });
139
174
  } catch (e: unknown) {
140
175
  console.error(e);
141
- res.status(500).end(e instanceof Error ? e.stack : e);
176
+ res.status(500).end(serverErrorBody(e, "stack"));
142
177
  }
143
178
  };
144
179
  }
@@ -0,0 +1,47 @@
1
+ import { afterEach, expect, test, vi } from "vitest";
2
+
3
+ afterEach(() => {
4
+ vi.unstubAllEnvs();
5
+ vi.resetModules();
6
+ });
7
+
8
+ // isProd читается при импорте, поэтому модуль грузим после stubEnv
9
+ const load = () => import("./helpers.js");
10
+
11
+ test("parseCookies не падает на кривой %-последовательности", async () => {
12
+ const { parseCookies } = await load();
13
+ expect(parseCookies("a=%E0%A4%A; b=%D0%BF")).toEqual({
14
+ a: "%E0%A4%A",
15
+ b: "п",
16
+ });
17
+ });
18
+
19
+ test("serverErrorBody в проде без подробностей", async () => {
20
+ vi.stubEnv("NODE_ENV", "production");
21
+ const { serverErrorBody } = await load();
22
+ expect(serverErrorBody(new Error("db password=x"), "stack")).toBe(
23
+ "Server Error",
24
+ );
25
+ });
26
+
27
+ test("serverErrorBody в dev — со стеком", async () => {
28
+ vi.stubEnv("NODE_ENV", "development");
29
+ const { serverErrorBody } = await load();
30
+ const body = serverErrorBody(new Error("boom"), "stack");
31
+ expect(body).toContain("boom");
32
+ expect(body).toContain("helpers.test.ts");
33
+ });
34
+
35
+ test("getRequestOrigin: PUBLIC_ORIGIN главнее заголовков, хвостовой / срезается", async () => {
36
+ vi.stubEnv("PUBLIC_ORIGIN", "https://bina.rent/");
37
+ const { getRequestOrigin } = await load();
38
+ const req = { protocol: "http", get: () => "internal:3000" };
39
+ expect(getRequestOrigin(req as never)).toBe("https://bina.rent");
40
+ });
41
+
42
+ test("getRequestOrigin: без PUBLIC_ORIGIN — протокол и host запроса", async () => {
43
+ vi.stubEnv("PUBLIC_ORIGIN", "");
44
+ const { getRequestOrigin } = await load();
45
+ const req = { protocol: "https", get: () => "tt.drus.dev" };
46
+ expect(getRequestOrigin(req as never)).toBe("https://tt.drus.dev");
47
+ });
@@ -1,4 +1,5 @@
1
1
  // Хелперы SSR-мидлвары.
2
+ import type { Request } from "express";
2
3
 
3
4
  export const isProd = process.env.NODE_ENV === "production";
4
5
 
@@ -9,9 +10,32 @@ export const parseCookies = (header?: string): Record<string, string> =>
9
10
  .split(";")
10
11
  .map((part) => part.trim().split("="))
11
12
  .filter((pair) => pair.length === 2)
12
- .map(([k, v]) => [k!, decodeURIComponent(v!)]),
13
+ .map(([k, v]) => [k!, safeDecode(v!)]),
13
14
  );
14
15
 
16
+ // кривая %-последовательность в чужой cookie не должна ронять рендер
17
+ // всей страницы в 500 — оставляем значение как есть
18
+ const safeDecode = (value: string) => {
19
+ try {
20
+ return decodeURIComponent(value);
21
+ } catch {
22
+ return value;
23
+ }
24
+ };
25
+
26
+ /**
27
+ * Тело ответа при ошибке рендера. В проде — без подробностей: стек и
28
+ * сообщение раскрывают пути, версии и внутренности приложения.
29
+ */
30
+ export const serverErrorBody = (
31
+ error: unknown,
32
+ detail: "message" | "stack",
33
+ ): string => {
34
+ if (isProd) return "Server Error";
35
+ const text = error instanceof Error ? error[detail] : undefined;
36
+ return `Server Error: ${text ?? String(error)}`;
37
+ };
38
+
15
39
  /**
16
40
  * Атомы react-плагина. Объявлены в его аугментации Quark, но читаются
17
41
  * структурно: значения нужны как есть, до готовности плагина.
@@ -24,3 +48,17 @@ export type ReactAtoms = {
24
48
 
25
49
  export const getReactAtoms = (): ReactAtoms | undefined =>
26
50
  (globalThis as { quark?: { react?: ReactAtoms } }).quark?.react;
51
+
52
+ /**
53
+ * Абсолютный origin запроса для canonical/hreflang/JSON-LD.
54
+ *
55
+ * PUBLIC_ORIGIN главнее заголовков: за прокси Host может быть внутренним, а
56
+ * канонический адрес сайта оператор знает точно. Без него — протокол и host
57
+ * из запроса (req.protocol учитывает X-Forwarded-Proto при TRUST_PROXY).
58
+ */
59
+ export const getRequestOrigin = (req: Request): string => {
60
+ const configured = process.env.PUBLIC_ORIGIN?.trim();
61
+ if (configured) return configured.replace(/\/$/, "");
62
+ const host = req.get("host");
63
+ return host ? `${req.protocol}://${host}` : "";
64
+ };
@@ -1,109 +1,109 @@
1
- import type { InlineConfig, PluginOption } from "vite";
2
- import react from "@vitejs/plugin-react";
3
- import tsconfigPaths from "vite-tsconfig-paths";
4
- import svgr from "vite-plugin-svgr";
5
- import { patchCssModules } from "vite-css-modules";
6
- import fs from "node:fs";
7
- import path from "node:path";
8
- import { fileURLToPath } from "node:url";
9
- import { loadModulesFromExternalFolderPlugin } from "./plugins.js";
10
-
11
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
-
13
- /**
14
- * Пакеты @quark/* в приложении — это симлинки на ИСХОДНИКИ соседних
15
- * workspace-пакетов. Если позволить vite их пре-бандлить, он кэширует
16
- * результат в node_modules/.vite и продолжает отдавать старую версию даже
17
- * после правок в пакете (правка видна только после ручной очистки кэша).
18
- * Поэтому исключаем их из оптимизации — тогда HMR работает как для
19
- * обычных файлов приложения.
20
- */
21
- const workspacePackages = (cwd: string): string[] => {
22
- try {
23
- const pkg = JSON.parse(
24
- fs.readFileSync(path.join(cwd, "package.json"), "utf8"),
25
- ) as Record<string, Record<string, string> | undefined>;
26
- return Object.keys({
27
- ...(pkg.dependencies ?? {}),
28
- ...(pkg.devDependencies ?? {}),
29
- }).filter(
30
- (name) =>
31
- name.startsWith("@quark/") || name.startsWith("@quark-fw/"),
32
- );
33
- } catch {
34
- return [];
35
- }
36
- };
37
-
38
- /** Плагин поставлен из npm (лежит внутри node_modules), а не симлинкован из монорепы. */
39
- const installedFromRegistry = fs
40
- .realpathSync(__dirname)
41
- .replace(/\\/g, "/")
42
- .includes("/node_modules/");
43
-
44
- /**
45
- * Реестр страниц (`import.meta.glob` по /Pages) лежит внутри самого плагина, то
46
- * есть в node_modules приложения. Такие модули vite отдаёт с
47
- * `Cache-Control: max-age=31536000, immutable`, считая их неизменяемыми, и
48
- * версионирует хэшем зависимостей — а он не меняется от появления новой
49
- * страницы в приложении. Браузер продолжает исполнять старый реестр: сервер
50
- * страницу отрисовал, а клиентский роутер её маршрута не знает и после
51
- * гидратации показывает 404 (лечилось только Ctrl+F5). Содержимое этого модуля
52
- * зависит от файлов приложения, поэтому кэшировать его нельзя.
53
- */
54
- const pageRegistryNoCachePlugin = (): PluginOption => ({
55
- name: "quark:page-registry-no-cache",
56
- apply: "serve",
57
- configureServer(server) {
58
- server.middlewares.use((req, res, next) => {
59
- if (req.url?.includes("/Router/gatherPages.")) {
60
- const setHeader = res.setHeader.bind(res);
61
- res.setHeader = (name, value) =>
62
- /^cache-control$/i.test(name)
63
- ? setHeader(name, "no-cache")
64
- : setHeader(name, value);
65
- }
66
- next();
67
- });
68
- },
69
- });
70
-
71
- /** Единая конфигурация vite для dev-сервера и для сборки. */
72
- export function createViteConfig(overrides?: InlineConfig): InlineConfig {
73
- return {
74
- plugins: [
75
- patchCssModules(),
76
- svgr({ include: "**/*.svg" }),
77
- react(),
78
- tsconfigPaths(),
79
- loadModulesFromExternalFolderPlugin(),
80
- pageRegistryNoCachePlugin(),
81
- ],
82
- // Установленный из реестра плагин лежит в node_modules, куда
83
- // @vitejs/plugin-react не заглядывает (у него exclude /node_modules/).
84
- // Без явного automatic-рантайма его собственный JSX собирается в
85
- // React.createElement и падает с "React is not defined".
86
- esbuild: { jsx: "automatic", jsxImportSource: "react" },
87
- optimizeDeps: {
88
- exclude: workspacePackages(process.cwd()),
89
- // Плагин исключён из оптимизации, поэтому vite не сканирует его
90
- // импорты: установленный из реестра, он тянет react-router и mantine
91
- // сырыми, и CJS-пакеты внутри них (cookie) падают на named-импортах.
92
- // В монорепо, наоборот, форсировать пре-бандл нельзя — рядом с
93
- // симлинкованными исходниками появилась бы вторая копия React.
94
- include: installedFromRegistry
95
- ? [
96
- "react",
97
- "react-dom",
98
- "react-dom/client",
99
- "react-router-dom",
100
- "@mantine/core",
101
- "@mantine/dates",
102
- "@mantine/modals",
103
- "@mantine/notifications",
104
- ]
105
- : [],
106
- },
107
- ...overrides,
108
- };
109
- }
1
+ import type { InlineConfig, PluginOption } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import tsconfigPaths from "vite-tsconfig-paths";
4
+ import svgr from "vite-plugin-svgr";
5
+ import { patchCssModules } from "vite-css-modules";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { loadModulesFromExternalFolderPlugin } from "./plugins.js";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ /**
14
+ * Пакеты @quark/* в приложении — это симлинки на ИСХОДНИКИ соседних
15
+ * workspace-пакетов. Если позволить vite их пре-бандлить, он кэширует
16
+ * результат в node_modules/.vite и продолжает отдавать старую версию даже
17
+ * после правок в пакете (правка видна только после ручной очистки кэша).
18
+ * Поэтому исключаем их из оптимизации — тогда HMR работает как для
19
+ * обычных файлов приложения.
20
+ */
21
+ const workspacePackages = (cwd: string): string[] => {
22
+ try {
23
+ const pkg = JSON.parse(
24
+ fs.readFileSync(path.join(cwd, "package.json"), "utf8"),
25
+ ) as Record<string, Record<string, string> | undefined>;
26
+ return Object.keys({
27
+ ...(pkg.dependencies ?? {}),
28
+ ...(pkg.devDependencies ?? {}),
29
+ }).filter(
30
+ (name) =>
31
+ name.startsWith("@quark/") || name.startsWith("@quark-fw/"),
32
+ );
33
+ } catch {
34
+ return [];
35
+ }
36
+ };
37
+
38
+ /** Плагин поставлен из npm (лежит внутри node_modules), а не симлинкован из монорепы. */
39
+ const installedFromRegistry = fs
40
+ .realpathSync(__dirname)
41
+ .replace(/\\/g, "/")
42
+ .includes("/node_modules/");
43
+
44
+ /**
45
+ * Реестр страниц (`import.meta.glob` по /Pages) лежит внутри самого плагина, то
46
+ * есть в node_modules приложения. Такие модули vite отдаёт с
47
+ * `Cache-Control: max-age=31536000, immutable`, считая их неизменяемыми, и
48
+ * версионирует хэшем зависимостей — а он не меняется от появления новой
49
+ * страницы в приложении. Браузер продолжает исполнять старый реестр: сервер
50
+ * страницу отрисовал, а клиентский роутер её маршрута не знает и после
51
+ * гидратации показывает 404 (лечилось только Ctrl+F5). Содержимое этого модуля
52
+ * зависит от файлов приложения, поэтому кэшировать его нельзя.
53
+ */
54
+ const pageRegistryNoCachePlugin = (): PluginOption => ({
55
+ name: "quark:page-registry-no-cache",
56
+ apply: "serve",
57
+ configureServer(server) {
58
+ server.middlewares.use((req, res, next) => {
59
+ if (req.url?.includes("/Router/gatherPages.")) {
60
+ const setHeader = res.setHeader.bind(res);
61
+ res.setHeader = (name, value) =>
62
+ /^cache-control$/i.test(name)
63
+ ? setHeader(name, "no-cache")
64
+ : setHeader(name, value);
65
+ }
66
+ next();
67
+ });
68
+ },
69
+ });
70
+
71
+ /** Единая конфигурация vite для dev-сервера и для сборки. */
72
+ export function createViteConfig(overrides?: InlineConfig): InlineConfig {
73
+ return {
74
+ plugins: [
75
+ patchCssModules(),
76
+ svgr({ include: "**/*.svg" }),
77
+ react(),
78
+ tsconfigPaths(),
79
+ loadModulesFromExternalFolderPlugin(),
80
+ pageRegistryNoCachePlugin(),
81
+ ],
82
+ // Установленный из реестра плагин лежит в node_modules, куда
83
+ // @vitejs/plugin-react не заглядывает (у него exclude /node_modules/).
84
+ // Без явного automatic-рантайма его собственный JSX собирается в
85
+ // React.createElement и падает с "React is not defined".
86
+ esbuild: { jsx: "automatic", jsxImportSource: "react" },
87
+ optimizeDeps: {
88
+ exclude: workspacePackages(process.cwd()),
89
+ // Плагин исключён из оптимизации, поэтому vite не сканирует его
90
+ // импорты: установленный из реестра, он тянет react-router и mantine
91
+ // сырыми, и CJS-пакеты внутри них (cookie) падают на named-импортах.
92
+ // В монорепо, наоборот, форсировать пре-бандл нельзя — рядом с
93
+ // симлинкованными исходниками появилась бы вторая копия React.
94
+ include: installedFromRegistry
95
+ ? [
96
+ "react",
97
+ "react-dom",
98
+ "react-dom/client",
99
+ "react-router-dom",
100
+ "@mantine/core",
101
+ "@mantine/dates",
102
+ "@mantine/modals",
103
+ "@mantine/notifications",
104
+ ]
105
+ : [],
106
+ },
107
+ ...overrides,
108
+ };
109
+ }