@quark-fw/plugin-react 0.1.6 → 0.1.8

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.
@@ -1,34 +1,12 @@
1
1
  /// <reference types="vite/client" />
2
- import React from "react";
3
- import { usePromise } from "../utils/usePromise.js";
2
+ // Синглтоны загруженных страниц/лейаутов приложения (glob по /Pages).
3
+ import { createUglyModules } from "./createUglyModules.js";
4
+ import type { TPageModule, ViteModules } from "./types.js";
4
5
 
5
- /*
6
- * usePromise вызывается из класса-загрузчика, а не из компонента: чтение
7
- * модуля происходит в теле рендера страницы, поэтому порядок вызовов совпадает
8
- * с порядком рендеров. Ответственность за это на роутере, который читает
9
- * страницу безусловно.
10
- */
11
- /* eslint-disable react-hooks/rules-of-hooks */
12
-
13
- /**
14
- * Модуль страницы. Пропсы страниц описывает роутер (UglyPageProps), но
15
- * загрузчик их не разбирает — ему важно лишь, что это компонент.
16
- */
17
- export type TPageModule = {
18
- default: React.ComponentType<Record<string, unknown>>;
19
- };
20
-
21
- type TUrlPath = string;
22
- type TModulePath = string;
23
- type TFilePath = string;
24
-
25
- export type ViteModules = {
26
- [key: TModulePath]: () => Promise<TPageModule>;
27
- };
28
-
29
- export type ViteModulesEager = {
30
- [key: TModulePath]: TPageModule;
31
- };
6
+ // реэкспорт для прежних импортёров этого модуля
7
+ export { UglyModule } from "./UglyModule.js";
8
+ export { createUglyModules } from "./createUglyModules.js";
9
+ export type { TPageModule, ViteModules, ViteModulesEager } from "./types.js";
32
10
 
33
11
  const pages: ViteModules = import.meta.glob<TPageModule>("/Pages/**/Page.*");
34
12
  const layouts: ViteModules = import.meta.glob<TPageModule>(
@@ -38,126 +16,6 @@ const resetLayouts: ViteModules = import.meta.glob<TPageModule>(
38
16
  "/Pages/**/LayoutReset.*",
39
17
  );
40
18
 
41
- const getMeta = (modulePath: TModulePath): [TFilePath, TUrlPath] => {
42
- const filePath = modulePath.replace(/^\/Pages/, "").replace(/(\.tsx)$/, "");
43
- let urlPath = (
44
- filePath.endsWith("/Page")
45
- ? filePath.replace(/Page$/, "")
46
- : `${filePath}/`
47
- )
48
- .replace(/\[\.{3}.+\]/, "*")
49
- .replaceAll(/\[(.[^[]+)\]/g, ":$1")
50
- .toLowerCase();
51
-
52
- urlPath = urlPath === "/main/" ? "/" : urlPath;
53
-
54
- return [filePath, urlPath];
55
- };
56
-
57
- export class UglyModule {
58
- modulePath: string;
59
-
60
- moduleImport: () => Promise<TPageModule>;
61
-
62
- module?: TPageModule;
63
-
64
- loadingPromise?: Promise<TPageModule>;
65
-
66
- wrappedPromise?: () => TPageModule;
67
-
68
- filePath: string;
69
-
70
- urlPath: string;
71
-
72
- constructor({
73
- moduleImport,
74
- modulePath,
75
- module,
76
- }: {
77
- moduleImport: () => Promise<TPageModule>;
78
- modulePath: TModulePath;
79
- module?: TPageModule;
80
- }) {
81
- this.modulePath = modulePath;
82
- this.moduleImport = moduleImport;
83
- this.module = module;
84
- // this.loadingPromise;
85
- const [filePath, urlPath] = getMeta(modulePath);
86
- this.filePath = filePath;
87
- this.urlPath = urlPath;
88
- }
89
-
90
- getWrappedPromise(force?: TPageModule) {
91
- if (!this.module && !force) {
92
- if (!this.wrappedPromise) {
93
- if (!this.loadingPromise) {
94
- this.loadingPromise = new Promise((resolve) => {
95
- try {
96
- this.moduleImport().then((module) =>
97
- resolve(module),
98
- );
99
- } catch (e) {
100
- console.error(e);
101
- }
102
- });
103
- }
104
- this.wrappedPromise = usePromise(this.loadingPromise);
105
- }
106
-
107
- return this.wrappedPromise();
108
- }
109
- this.wrappedPromise = usePromise(
110
- new Promise((resolve) => {
111
- resolve(this.module! || force);
112
- }),
113
- );
114
- return this.module;
115
- }
116
-
117
- async load() {
118
- if (!this.module) {
119
- if (!this.loadingPromise) {
120
- this.loadingPromise = new Promise((resolve) => {
121
- try {
122
- this.moduleImport().then((module) => resolve(module));
123
- } catch (e) {
124
- console.error(e);
125
- }
126
- });
127
- }
128
- }
129
-
130
- this.module = await this.loadingPromise;
131
-
132
- return this.module;
133
- }
134
- }
135
-
136
- export const createUglyModules = (modules: ViteModules | ViteModulesEager) =>
137
- Object.keys(modules).reduce(
138
- (acc, modulePath) => {
139
- const moduleImport = modules[modulePath];
140
- if (typeof moduleImport === "object") {
141
- acc[modulePath] = new UglyModule({
142
- modulePath,
143
- module: moduleImport,
144
- moduleImport: () =>
145
- new Promise<TPageModule>((resolve) =>
146
- resolve(moduleImport),
147
- ),
148
- });
149
- } else {
150
- acc[modulePath] = new UglyModule({
151
- modulePath,
152
- moduleImport: moduleImport!,
153
- });
154
- }
155
-
156
- return acc;
157
- },
158
- {} as Record<TModulePath, UglyModule>,
159
- );
160
-
161
19
  const uglyPages = createUglyModules(pages);
162
20
  const uglyLayouts = createUglyModules(layouts);
163
21
  const uglyResetLayouts = createUglyModules(resetLayouts);
@@ -0,0 +1,30 @@
1
+ // Типы, общие для файлов роутера (страницы, загрузчик модулей, пропсы).
2
+ import type { ComponentType, ReactNode } from "react";
3
+
4
+ /**
5
+ * Модуль страницы. Пропсы страниц описывает роутер (UglyPageProps), но
6
+ * загрузчик их не разбирает — ему важно лишь, что это компонент.
7
+ */
8
+ export type TPageModule = {
9
+ default: ComponentType<Record<string, unknown>>;
10
+ };
11
+
12
+ export type TModulePath = string;
13
+
14
+ export type ViteModules = {
15
+ [key: TModulePath]: () => Promise<TPageModule>;
16
+ };
17
+
18
+ export type ViteModulesEager = {
19
+ [key: TModulePath]: TPageModule;
20
+ };
21
+
22
+ export type UglyPageProps = {
23
+ pathname: string;
24
+ params: Record<string, string>;
25
+ breadCrumbs: ReactNode | null;
26
+ };
27
+
28
+ export type UglyLayoutProps = UglyPageProps & {
29
+ children: ReactNode;
30
+ };
package/src/build.ts CHANGED
@@ -5,9 +5,9 @@ import {
5
5
  appFrontendRoot,
6
6
  buildPaths,
7
7
  clientEntry,
8
- createViteConfig,
9
8
  serverEntry,
10
9
  } from "./utils/viteConfig.js";
10
+ import { createViteConfig } from "./utils/createViteConfig.js";
11
11
 
12
12
  /**
13
13
  * Прод-сборка приложения: клиентский бандл (с manifest) и SSR-бандл.
@@ -10,9 +10,10 @@ import { ModalsProvider } from "@mantine/modals";
10
10
  import { Notifications } from "@mantine/notifications";
11
11
  import { MantineProvider } from "@mantine/core";
12
12
 
13
- import { UglyModule, ViteModulesEager } from "../Router/gatherPages.js";
13
+ import { UglyModule } from "../Router/UglyModule.js";
14
+ import type { ViteModulesEager } from "../Router/types.js";
14
15
  import { ErrorHandler } from "./ErrorHandler.js";
15
- import { UglyRouter } from "../Router/Router.js";
16
+ import { UglyRouter } from "../Router/UglyRouter.js";
16
17
  import Store, {
17
18
  StoreContext,
18
19
  TRPCContext,
@@ -20,8 +21,9 @@ import Store, {
20
21
  } from "@quark-fw/store";
21
22
  // регистрирует показ ошибок через notifications (side-effect импорт)
22
23
  import "./displayError.js";
23
- import { SeoProvider, type SeoMap } from "../seo/SeoContext.js";
24
- import { I18nProvider, type EagerLocaleModules } from "../i18n/I18nContext.js";
24
+ import { SeoProvider } from "../seo/SeoProvider.js";
25
+ import type { SeoMap } from "../seo/SeoContext.js";
26
+ import { I18nProvider, type EagerLocaleModules } from "../i18n/I18nProvider.js";
25
27
  import type { I18nConfig } from "../i18n/config.js";
26
28
  // @ts-expect-error virtual file
27
29
  import { appTheme } from "/theme.ts";
@@ -4,13 +4,14 @@ import { startTransition } from "react";
4
4
  import Store, { trpcClient } from "@quark-fw/store";
5
5
  import { getRenderPath } from "../Router/getRenderPath.js";
6
6
  import { parseLocalePath, type I18nConfig } from "../i18n/config.js";
7
+ import type { SeoMap } from "../seo/SeoContext.js";
7
8
  import App from "./App.js";
8
9
 
9
10
  /** то, что SSR кладёт в window для гидратации */
10
11
  type HydrationWindow = typeof window & {
11
12
  cache?: Record<string, unknown>;
12
13
  store?: Store;
13
- seo?: Record<string, unknown>;
14
+ seo?: SeoMap;
14
15
  };
15
16
 
16
17
  const hydrationWindow = window as HydrationWindow;
@@ -31,7 +32,8 @@ if (rootEl) {
31
32
  );
32
33
  const { path } = getRenderPath(strippedPathname);
33
34
 
34
- void Promise.all(path.map((e) => e.load()));
35
+ // прогрев чанков; ошибка не фатальна — рендер сам продолжит загрузку
36
+ void Promise.all(path.map((e) => e.load())).catch(console.error);
35
37
 
36
38
  const parsedProps = Object.keys(rootEl.dataset).reduce(
37
39
  (acc, key) => {
@@ -16,7 +16,7 @@ import HTML 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";
19
- import type { EagerLocaleModules } from "../i18n/I18nContext.js";
19
+ import type { EagerLocaleModules } from "../i18n/I18nProvider.js";
20
20
 
21
21
  // i18n опционален: если у приложения нет /i18n.ts — glob вернёт пустой объект
22
22
  const i18nModules: Record<string, { i18nConfig?: I18nConfig }> =
@@ -1,13 +1,4 @@
1
- /// <reference types="vite/client" />
2
- import { createContext, ReactNode, useContext, useMemo } from "react";
3
- import { useStore } from "@quark-fw/store";
4
- import { usePromise } from "@quark-fw/store/src/usePromise.js";
5
- import {
6
- buildLocalePath,
7
- usesLocalePrefix,
8
- type I18nConfig,
9
- } from "./config.js";
10
- import { getLocaleImporter, type LocaleModule } from "./gatherLocales.js";
1
+ import { createContext, useContext } from "react";
11
2
 
12
3
  export type Translate = (
13
4
  key: string,
@@ -33,185 +24,4 @@ const I18nContext = createContext<I18nContextValue>({
33
24
 
34
25
  export const useI18n = () => useContext(I18nContext);
35
26
 
36
- export type EagerLocaleModules = Record<string, LocaleModule>;
37
-
38
- const deepMerge = (base: unknown, override: unknown): unknown => {
39
- if (
40
- typeof base !== "object" ||
41
- base === null ||
42
- typeof override !== "object" ||
43
- override === null
44
- ) {
45
- return typeof override === "undefined" ? base : override;
46
- }
47
- const source = base as Record<string, unknown>;
48
- const patch = override as Record<string, unknown>;
49
- const out: Record<string, unknown> = { ...source };
50
- for (const key of Object.keys(patch)) {
51
- out[key] = deepMerge(source[key], patch[key]);
52
- }
53
- return out;
54
- };
55
-
56
- const interpolate = (
57
- template: string,
58
- params?: Record<string, string | number>,
59
- ) =>
60
- params
61
- ? template.replace(/\{(\w+)\}/g, (match, name) =>
62
- name in params ? String(params[name]) : match,
63
- )
64
- : template;
65
-
66
- export function I18nProvider({
67
- locale,
68
- config,
69
- eagerLocaleModules,
70
- children,
71
- }: {
72
- locale: string;
73
- config: I18nConfig;
74
- /** передаётся только на сервере (eager-glob из entry-server) */
75
- eagerLocaleModules?: EagerLocaleModules;
76
- children: ReactNode;
77
- }) {
78
- const store = useStore();
79
-
80
- const value: I18nContextValue = useMemo(() => {
81
- const defaultLocale = config?.defaultLocale || locale;
82
- let pluralRules: Intl.PluralRules;
83
- try {
84
- pluralRules = new Intl.PluralRules(locale || undefined);
85
- } catch {
86
- pluralRules = new Intl.PluralRules();
87
- }
88
-
89
- /*
90
- * usePromise ниже — хук, вызванный не из компонента: словарь читается в
91
- * теле рендера потребителя ($), поэтому порядок вызовов совпадает с
92
- * порядком чтений. Кэш стора при этом заполняется намеренно — это его
93
- * назначение, а не мутация чужого состояния.
94
- */
95
- /* eslint-disable react-hooks/rules-of-hooks, react-hooks/immutability */
96
- const loadNamespace = (ns: string, cacheKey: string): unknown => {
97
- if (eagerLocaleModules) {
98
- // SSR: синхронно склеиваем словари и стримим их клиенту
99
- const def =
100
- eagerLocaleModules[`/locales/${defaultLocale}/${ns}.ts`]
101
- ?.default;
102
- const cur =
103
- locale === defaultLocale
104
- ? undefined
105
- : eagerLocaleModules[`/locales/${locale}/${ns}.ts`]
106
- ?.default;
107
- const data = deepMerge(def ?? {}, cur ?? {});
108
- store.values[cacheKey] = data;
109
- store.callback?.(cacheKey, data);
110
- return data;
111
- }
112
-
113
- // клиент: подвешиваемся на ленивые чанки vite
114
- const { reactPromises } = store;
115
- if (!reactPromises[cacheKey]) {
116
- const importDefault = getLocaleImporter(defaultLocale, ns);
117
- const importCurrent =
118
- locale === defaultLocale
119
- ? undefined
120
- : getLocaleImporter(locale, ns);
121
- if (!importDefault && !importCurrent) {
122
- store.values[cacheKey] = {};
123
- return store.values[cacheKey];
124
- }
125
- reactPromises[cacheKey] = usePromise(
126
- Promise.all([importDefault?.(), importCurrent?.()]).then(
127
- ([def, cur]) => {
128
- const merged = deepMerge(
129
- def?.default ?? {},
130
- cur?.default ?? {},
131
- );
132
- store.values[cacheKey] = merged;
133
- return merged;
134
- },
135
- ),
136
- );
137
- }
138
- return reactPromises[cacheKey]();
139
- };
140
-
141
- const $: Translate = (key, params) => {
142
- const dot = key.indexOf(".");
143
- const ns = dot === -1 ? key : key.slice(0, dot);
144
- const rest = dot === -1 ? "" : key.slice(dot + 1);
145
- const cacheKey = `i18n:${locale}:${ns}`;
146
-
147
- let data = store.values[cacheKey];
148
- if (typeof data === "undefined") data = loadNamespace(ns, cacheKey);
149
-
150
- // по словарю идём как по дереву неизвестной формы
151
- const asDict = (value: unknown) =>
152
- value && typeof value === "object"
153
- ? (value as Record<string, unknown>)
154
- : undefined;
155
-
156
- let node: unknown = data;
157
- for (const part of rest ? rest.split(".") : []) {
158
- node = asDict(node)?.[part];
159
- }
160
-
161
- if (typeof node === "string") return interpolate(node, params);
162
-
163
- // плюрализация: { one: "…", few: "…", other: "…" } + params.count
164
- const forms = asDict(node);
165
- if (forms && typeof forms.other === "string") {
166
- const count = Number(params?.count);
167
- const category = Number.isFinite(count)
168
- ? pluralRules.select(count)
169
- : "other";
170
- const form = forms[category];
171
- return interpolate(
172
- typeof form === "string" ? form : forms.other,
173
- params,
174
- );
175
- }
176
-
177
- return key;
178
- };
179
-
180
- const setLocale = (next: string) => {
181
- if (
182
- !config?.locales.includes(next) ||
183
- typeof window === "undefined"
184
- )
185
- return;
186
- document.cookie = `locale=${next};path=/;max-age=31536000`;
187
- if (!usesLocalePrefix(config)) {
188
- window.location.reload();
189
- return;
190
- }
191
- const { pathname, search, hash } = window.location;
192
- const stripped = pathname.startsWith(`/${locale}`)
193
- ? pathname.slice(locale.length + 1) || "/"
194
- : pathname;
195
- window.location.href =
196
- buildLocalePath(next, stripped) + search + hash;
197
- };
198
-
199
- return {
200
- locale,
201
- locales: config?.locales ?? [],
202
- defaultLocale,
203
- $,
204
- setLocale,
205
- };
206
- // config и eagerLocaleModules приходят из модулей приложения (/i18n.ts
207
- // и glob по /locales) и за время жизни страницы не меняются —
208
- // пересобирать контекст на их идентичность незачем
209
- // eslint-disable-next-line react-hooks/exhaustive-deps
210
- }, [locale, store]);
211
-
212
- return (
213
- <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
214
- );
215
- }
216
-
217
27
  export default I18nContext;
@@ -0,0 +1,168 @@
1
+ /// <reference types="vite/client" />
2
+ import { ReactNode, useMemo } from "react";
3
+ import { useStore } from "@quark-fw/store";
4
+ import { usePromise } from "@quark-fw/store/src/usePromise.js";
5
+ import {
6
+ buildLocalePath,
7
+ usesLocalePrefix,
8
+ type I18nConfig,
9
+ } from "./config.js";
10
+ import { getLocaleImporter, type LocaleModule } from "./gatherLocales.js";
11
+ import { deepMerge, interpolate } from "./helpers.js";
12
+ import I18nContext, {
13
+ type I18nContextValue,
14
+ type Translate,
15
+ } from "./I18nContext.js";
16
+
17
+ export type EagerLocaleModules = Record<string, LocaleModule>;
18
+
19
+ export function I18nProvider({
20
+ locale,
21
+ config,
22
+ eagerLocaleModules,
23
+ children,
24
+ }: {
25
+ locale: string;
26
+ config: I18nConfig;
27
+ /** передаётся только на сервере (eager-glob из entry-server) */
28
+ eagerLocaleModules?: EagerLocaleModules;
29
+ children: ReactNode;
30
+ }) {
31
+ const store = useStore();
32
+
33
+ const value: I18nContextValue = useMemo(() => {
34
+ const defaultLocale = config?.defaultLocale || locale;
35
+ let pluralRules: Intl.PluralRules;
36
+ try {
37
+ pluralRules = new Intl.PluralRules(locale || undefined);
38
+ } catch {
39
+ pluralRules = new Intl.PluralRules();
40
+ }
41
+
42
+ /*
43
+ * usePromise ниже — хук, вызванный не из компонента: словарь читается в
44
+ * теле рендера потребителя ($), поэтому порядок вызовов совпадает с
45
+ * порядком чтений. Кэш стора при этом заполняется намеренно — это его
46
+ * назначение, а не мутация чужого состояния.
47
+ */
48
+ /* eslint-disable react-hooks/rules-of-hooks, react-hooks/immutability */
49
+ const loadNamespace = (ns: string, cacheKey: string): unknown => {
50
+ if (eagerLocaleModules) {
51
+ // SSR: синхронно склеиваем словари и стримим их клиенту
52
+ const def =
53
+ eagerLocaleModules[`/locales/${defaultLocale}/${ns}.ts`]
54
+ ?.default;
55
+ const cur =
56
+ locale === defaultLocale
57
+ ? undefined
58
+ : eagerLocaleModules[`/locales/${locale}/${ns}.ts`]
59
+ ?.default;
60
+ const data = deepMerge(def ?? {}, cur ?? {});
61
+ store.values[cacheKey] = data;
62
+ store.callback?.(cacheKey, data);
63
+ return data;
64
+ }
65
+
66
+ // клиент: подвешиваемся на ленивые чанки vite
67
+ const { reactPromises } = store;
68
+ if (!reactPromises[cacheKey]) {
69
+ const importDefault = getLocaleImporter(defaultLocale, ns);
70
+ const importCurrent =
71
+ locale === defaultLocale
72
+ ? undefined
73
+ : getLocaleImporter(locale, ns);
74
+ if (!importDefault && !importCurrent) {
75
+ store.values[cacheKey] = {};
76
+ return store.values[cacheKey];
77
+ }
78
+ reactPromises[cacheKey] = usePromise(
79
+ Promise.all([importDefault?.(), importCurrent?.()]).then(
80
+ ([def, cur]) => {
81
+ const merged = deepMerge(
82
+ def?.default ?? {},
83
+ cur?.default ?? {},
84
+ );
85
+ store.values[cacheKey] = merged;
86
+ return merged;
87
+ },
88
+ ),
89
+ );
90
+ }
91
+ return reactPromises[cacheKey]();
92
+ };
93
+
94
+ const $: Translate = (key, params) => {
95
+ const dot = key.indexOf(".");
96
+ const ns = dot === -1 ? key : key.slice(0, dot);
97
+ const rest = dot === -1 ? "" : key.slice(dot + 1);
98
+ const cacheKey = `i18n:${locale}:${ns}`;
99
+
100
+ let data = store.values[cacheKey];
101
+ if (typeof data === "undefined") data = loadNamespace(ns, cacheKey);
102
+
103
+ // по словарю идём как по дереву неизвестной формы
104
+ const asDict = (value: unknown) =>
105
+ value && typeof value === "object"
106
+ ? (value as Record<string, unknown>)
107
+ : undefined;
108
+
109
+ let node: unknown = data;
110
+ for (const part of rest ? rest.split(".") : []) {
111
+ node = asDict(node)?.[part];
112
+ }
113
+
114
+ if (typeof node === "string") return interpolate(node, params);
115
+
116
+ // плюрализация: { one: "…", few: "…", other: "…" } + params.count
117
+ const forms = asDict(node);
118
+ if (forms && typeof forms.other === "string") {
119
+ const count = Number(params?.count);
120
+ const category = Number.isFinite(count)
121
+ ? pluralRules.select(count)
122
+ : "other";
123
+ const form = forms[category];
124
+ return interpolate(
125
+ typeof form === "string" ? form : forms.other,
126
+ params,
127
+ );
128
+ }
129
+
130
+ return key;
131
+ };
132
+
133
+ const setLocale = (next: string) => {
134
+ if (
135
+ !config?.locales.includes(next) ||
136
+ typeof window === "undefined"
137
+ )
138
+ return;
139
+ document.cookie = `locale=${next};path=/;max-age=31536000`;
140
+ if (!usesLocalePrefix(config)) {
141
+ window.location.reload();
142
+ return;
143
+ }
144
+ const { pathname, search, hash } = window.location;
145
+ const stripped = pathname.startsWith(`/${locale}`)
146
+ ? pathname.slice(locale.length + 1) || "/"
147
+ : pathname;
148
+ window.location.href =
149
+ buildLocalePath(next, stripped) + search + hash;
150
+ };
151
+
152
+ return {
153
+ locale,
154
+ locales: config?.locales ?? [],
155
+ defaultLocale,
156
+ $,
157
+ setLocale,
158
+ };
159
+ // config и eagerLocaleModules приходят из модулей приложения (/i18n.ts
160
+ // и glob по /locales) и за время жизни страницы не меняются —
161
+ // пересобирать контекст на их идентичность незачем
162
+ // eslint-disable-next-line react-hooks/exhaustive-deps
163
+ }, [locale, store]);
164
+
165
+ return (
166
+ <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
167
+ );
168
+ }
@@ -0,0 +1,29 @@
1
+ // Хелперы i18n: слияние словарей и подстановка параметров.
2
+
3
+ export const deepMerge = (base: unknown, override: unknown): unknown => {
4
+ if (
5
+ typeof base !== "object" ||
6
+ base === null ||
7
+ typeof override !== "object" ||
8
+ override === null
9
+ ) {
10
+ return typeof override === "undefined" ? base : override;
11
+ }
12
+ const source = base as Record<string, unknown>;
13
+ const patch = override as Record<string, unknown>;
14
+ const out: Record<string, unknown> = { ...source };
15
+ for (const key of Object.keys(patch)) {
16
+ out[key] = deepMerge(source[key], patch[key]);
17
+ }
18
+ return out;
19
+ };
20
+
21
+ export const interpolate = (
22
+ template: string,
23
+ params?: Record<string, string | number>,
24
+ ) =>
25
+ params
26
+ ? template.replace(/\{(\w+)\}/g, (match, name) =>
27
+ name in params ? String(params[name]) : match,
28
+ )
29
+ : template;