@bloklabs/react 1.16.0 → 2.0.0

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,406 +0,0 @@
1
- import { createContext, useContext, useMemo, type ComponentType, type ReactElement } from 'react';
2
- import { markSanitizerConfig } from '@bloklabs/core/adapters';
3
- import {
4
- BLOK_PORTAL_REGISTRY_CONFIG_KEY,
5
- BLOK_TOOL_NAME_CONFIG_KEY,
6
- type BlockPortalRegistry,
7
- } from './block-portal-registry';
8
- import type { API, SanitizerConfig } from '@/types';
9
- import type { MarkSnapshot, MarkSpec } from '@/types';
10
- import type { InlineTool, InlineToolConstructorOptions, MenuConfig } from '@/types/tools';
11
-
12
- /** Adapter-internal config keys, stripped before the config reaches the component. */
13
- const INTERNAL_CONFIG_KEYS: readonly string[] = [
14
- BLOK_PORTAL_REGISTRY_CONFIG_KEY,
15
- BLOK_TOOL_NAME_CONFIG_KEY,
16
- ];
17
-
18
- /**
19
- * Unique portal-registry key per tool instance (inline tools have no block id
20
- * to key on, and multiple instances of the same tool can coexist — e.g. the
21
- * visible toolbar's instance plus a shortcut render-probe).
22
- * @param toolName - name the tool is registered under
23
- */
24
- const generatePortalKey = (toolName: string): string =>
25
- `__blok-inline-tool__:${toolName}:${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
26
-
27
- /** Props handed to a React inline tool's `component` (its toolbar icon/UI). */
28
- export interface ReactInlineToolRenderProps<Config = Record<string, unknown>> {
29
- /**
30
- * Whether the tool's formatting is active for the current selection —
31
- * derived from the spec's `checkState` every time the toolbar queries it.
32
- * Pushed IN PLACE (no remount), so the icon restyles live.
33
- */
34
- active: boolean;
35
- /**
36
- * The tool's `config` from the consumer's `tools` map (adapter-internal
37
- * keys stripped) — the channel for host props (palette, locale…).
38
- */
39
- config: Readonly<Partial<Config>>;
40
- }
41
-
42
- /** The spec's mark operations, bound to the live selection via `api.marks`. */
43
- export interface InlineToolMarkOps<State = void> {
44
- /** Whether the whole selection currently carries the mark. */
45
- has(): boolean;
46
- /** Toggle the mark on the selection; returns true when now applied. */
47
- toggle(state?: State): boolean;
48
- /** Apply (or update in place) the mark on the selection. */
49
- apply(state?: State): HTMLElement[];
50
- /** Remove the mark from the selection. */
51
- remove(): HTMLElement[];
52
- /** Read the mark's current declared values at the selection. */
53
- read(): MarkSnapshot | null;
54
- }
55
-
56
- /**
57
- * Everything a React inline tool's component (and any component nested in it —
58
- * swatches, popovers) can reach via {@link useInlineTool} without prop-drilling.
59
- */
60
- export interface InlineToolHandle<Config = Record<string, unknown>, State = void> {
61
- /** Live active state, same value as the component's `active` prop. */
62
- active: boolean;
63
- /** The tool's config, same value as the component's `config` prop. */
64
- config: Readonly<Partial<Config>>;
65
- /** The editor API handed to the tool's constructor (undefined outside core). */
66
- api: API | undefined;
67
- /**
68
- * Operations bound to the spec's `mark` — null when the spec declares no
69
- * mark or the editor api provides no `marks` surface.
70
- */
71
- mark: InlineToolMarkOps<State> | null;
72
- }
73
-
74
- const InlineToolContext = createContext<InlineToolHandle | null>(null);
75
-
76
- /**
77
- * Reach the enclosing React inline tool from any component inside its
78
- * portaled UI: live `active` state, the tool `config`, the editor `api`, and
79
- * the spec's mark operations bound to the live selection.
80
- */
81
- export function useInlineTool<Config = Record<string, unknown>, State = void>(): InlineToolHandle<Config, State> {
82
- const handle = useContext(InlineToolContext);
83
-
84
- if (handle === null) {
85
- throw new Error('useInlineTool() must be called from a component rendered by createReactInlineTool');
86
- }
87
-
88
- return handle as InlineToolHandle<Config, State>;
89
- }
90
-
91
- /** Spec for {@link createReactInlineTool}. */
92
- export interface CreateReactInlineToolSpec<Config = Record<string, unknown>, State = void> {
93
- /** Tool type name — the MenuConfig item name (fallback when the tools-map key is unavailable). */
94
- type: string;
95
- /** Optional toolbar item title (shown in overflow/search contexts). */
96
- title?: string;
97
- /**
98
- * Translation key for the toolbar label, resolved by core i18n as
99
- * `toolNames.{titleKey}` (or used verbatim when it contains a dot). Without
100
- * it a custom tool's label is only localizable through the legacy
101
- * capitalized-tool-name fallback.
102
- */
103
- titleKey?: string;
104
- /** The component rendered as the tool's toolbar icon/UI. */
105
- component: ComponentType<ReactInlineToolRenderProps<Config>>;
106
- /**
107
- * Declarative description of the wrapper this tool produces. When present,
108
- * `surround` (toggle), `checkState` (whole-range coverage) and `sanitize`
109
- * are derived from it — range-aware splitting, adjacent-run semantics and
110
- * the trailing-whitespace fix come from the editor's mark engine
111
- * (`api.marks`) with no DOM plumbing in the tool.
112
- */
113
- mark?: MarkSpec<State>;
114
- /**
115
- * Applies/removes the formatting on the LIVE selection's range (captured at
116
- * activation time). Receives the editor API as its second argument.
117
- * Takes precedence over the `mark` derivation.
118
- */
119
- surround?: (range: Range, api: API | undefined) => void;
120
- /**
121
- * Reports whether the formatting is active for the given selection.
122
- * Receives the editor API as its second argument.
123
- * Takes precedence over the `mark` derivation.
124
- */
125
- checkState?: (selection: Selection | null, api: API | undefined) => boolean;
126
- /** Keyboard shortcut (e.g. `CMD+SHIFT+C`). */
127
- shortcut?: string;
128
- /**
129
- * Inline sanitizer config declaring the markup this tool produces.
130
- * Defaults to the config derived from `mark` when one is declared.
131
- */
132
- sanitize?: SanitizerConfig;
133
- /** Whether the tool stays available in read-only mode. */
134
- isReadOnlySupported?: boolean;
135
- }
136
-
137
- /**
138
- * Author a first-party React inline tool. Returns an inline-tool constructable
139
- * registered exactly like a vanilla tool (`tools: { color: createReactInlineTool(...) }`).
140
- *
141
- * Mirrors the `createReactBlock` portal pattern: `render()` returns a
142
- * `MenuConfig` whose `icon` is a Blok-owned host element
143
- * (`data-blok-mutation-free`) the component is portaled into via the editor's
144
- * shared `BlockPortalHost` tree — app-level context (themes, styled-components
145
- * providers) reaches the icon with no consumer-managed `createRoot`, and
146
- * `destroy()` (called by the inline toolbar on close/teardown) unregisters the
147
- * portal so the component unmounts deterministically — no leaked React roots.
148
- *
149
- * The editor `api` handed to the constructor is kept: it reaches `surround`/
150
- * `checkState` as their second argument, powers the `mark` derivation, and is
151
- * exposed to the portaled component tree via {@link useInlineTool}.
152
- *
153
- * Without an injected registry (vanilla core, no `useBlok`), the tool still
154
- * renders a valid MenuConfig — the host element just stays empty.
155
- */
156
- export function createReactInlineTool<Config = Record<string, unknown>, State = void>(
157
- spec: CreateReactInlineToolSpec<Config, State>
158
- ): (new (options: InlineToolConstructorOptions) => InlineTool) & {
159
- readonly __isBlokReactInlineTool: true;
160
- readonly isInline: true;
161
- readonly type: string;
162
- readonly title: string | undefined;
163
- readonly titleKey: string;
164
- readonly shortcut: string | undefined;
165
- readonly mark: MarkSpec<State> | undefined;
166
- readonly sanitize: SanitizerConfig | undefined;
167
- readonly isReadOnlySupported: boolean;
168
- } {
169
- return class ReactInlineTool implements InlineTool {
170
- /** Marker so `useBlok` can detect react inline tools and inject the registry. */
171
- public static readonly __isBlokReactInlineTool = true as const;
172
- public static readonly isInline = true as const;
173
-
174
- /** The tool's type name — also the default translation key and mark tag context. */
175
- public static get type(): string {
176
- return spec.type;
177
- }
178
-
179
- public static get title(): string | undefined {
180
- return spec.title;
181
- }
182
-
183
- /**
184
- * Translation key core i18n resolves as `toolNames.{titleKey}`. Defaults to
185
- * the tool `type` so a custom mark localizes through the conventional
186
- * `toolNames.{type}` key (as built-in tools do) instead of silently falling
187
- * to the un-translated capitalized tool name. When the consumer supplies no
188
- * matching i18n entry, core still falls through to that same label — the
189
- * default is a no-op there and never a regression.
190
- */
191
- public static get titleKey(): string {
192
- return spec.titleKey ?? spec.type;
193
- }
194
-
195
- public static get shortcut(): string | undefined {
196
- return spec.shortcut;
197
- }
198
-
199
- /**
200
- * The declared mark spec (if any). Exposed as a static so consumers can
201
- * assert a tool's mark in unit tests without mocking the factory.
202
- */
203
- public static get mark(): MarkSpec<State> | undefined {
204
- return spec.mark;
205
- }
206
-
207
- public static get sanitize(): SanitizerConfig | undefined {
208
- if (spec.sanitize !== undefined) {
209
- return spec.sanitize;
210
- }
211
-
212
- return spec.mark !== undefined ? markSanitizerConfig(spec.mark) : undefined;
213
- }
214
-
215
- public static get isReadOnlySupported(): boolean {
216
- return spec.isReadOnlySupported ?? false;
217
- }
218
-
219
- private readonly registry: BlockPortalRegistry | undefined;
220
- /** The editor API — kept (not discarded) so the tool has the same reach as a vanilla one. */
221
- private readonly api: API | undefined;
222
- /** Name this tool is registered under in the consumer's `tools` map. */
223
- private readonly toolName: string;
224
- /** Unique portal-registry key — inline tools have no block id. */
225
- private readonly portalKey: string;
226
- /** The consumer's tool config with adapter-internal keys stripped. */
227
- private readonly toolConfig: Readonly<Partial<Config>>;
228
- /**
229
- * Stable provider component wrapping `spec.component` with the
230
- * {@link useInlineTool} context. Created once per instance so prop pushes
231
- * (setProps) re-render without remounting the consumer's component.
232
- */
233
- private readonly portalComponent: ComponentType<ReactInlineToolRenderProps<Config>>;
234
- /** Last active state pushed to the mounted component (change-dedup baseline). */
235
- private active = false;
236
- /** True while this instance owns a registered portal entry. */
237
- private registered = false;
238
-
239
- public constructor(options: InlineToolConstructorOptions) {
240
- const config: Record<string, unknown> = options?.config ?? {};
241
-
242
- this.api = options?.api;
243
- this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY] as BlockPortalRegistry | undefined;
244
-
245
- const toolName = config[BLOK_TOOL_NAME_CONFIG_KEY];
246
-
247
- this.toolName = typeof toolName === 'string' ? toolName : spec.type;
248
- this.portalKey = generatePortalKey(this.toolName);
249
-
250
- const publicConfig: Record<string, unknown> = {};
251
-
252
- for (const [key, value] of Object.entries(config)) {
253
- if (!INTERNAL_CONFIG_KEYS.includes(key)) {
254
- publicConfig[key] = value;
255
- }
256
- }
257
- this.toolConfig = Object.freeze(publicConfig) as Readonly<Partial<Config>>;
258
- this.portalComponent = this.createPortalComponent();
259
- }
260
-
261
- public render(): MenuConfig {
262
- // The Blok-owned host: React reconciles the icon portaled INTO it, and
263
- // this attribute makes core's MutationObserver ignore those mutations.
264
- const host = document.createElement('div');
265
-
266
- host.setAttribute('data-blok-mutation-free', 'true');
267
-
268
- if (this.registry !== undefined) {
269
- this.registry.register(this.portalKey, {
270
- hostEl: host,
271
- component: this.portalComponent as unknown as ComponentType<Record<string, unknown>>,
272
- props: { active: this.active, config: this.toolConfig },
273
- toolName: this.toolName,
274
- });
275
- this.registered = true;
276
- }
277
-
278
- return {
279
- name: this.toolName,
280
- title: spec.title,
281
- icon: host,
282
- onActivate: (): void => {
283
- this.activate();
284
- },
285
- isActive: (): boolean => this.refreshActiveState(),
286
- };
287
- }
288
-
289
- /**
290
- * Idempotent teardown, called by the inline toolbar when this instance is
291
- * dropped (toolbar close, editor destroy, or a throwaway render probe).
292
- * Unregisters the portal entry so React unmounts the icon component —
293
- * the unmount signal consumer-managed `createRoot` hacks never had.
294
- */
295
- public destroy(): void {
296
- if (!this.registered) {
297
- return;
298
- }
299
-
300
- this.registered = false;
301
- this.registry?.unregister(this.portalKey);
302
- }
303
-
304
- /**
305
- * Run the tool's formatting action: an explicit `surround` on the LIVE
306
- * selection's range (captured at activation time, matching
307
- * wrapLegacyInlineTool — never a range snapshotted at render), or the
308
- * mark derivation (`api.marks.toggle`) when only a `mark` is declared.
309
- */
310
- private activate(): void {
311
- if (typeof spec.surround === 'function') {
312
- const selection = window.getSelection();
313
-
314
- if (!selection || selection.rangeCount === 0) {
315
- return;
316
- }
317
-
318
- spec.surround(selection.getRangeAt(0), this.api);
319
-
320
- return;
321
- }
322
-
323
- if (spec.mark !== undefined && this.api?.marks !== undefined) {
324
- this.api.marks.toggle(spec.mark);
325
- }
326
- }
327
-
328
- /**
329
- * Recompute the active state from the live selection and push it to the
330
- * mounted component ONLY when it actually changed (no redundant
331
- * external-store notifications).
332
- */
333
- private refreshActiveState(): boolean {
334
- const active = this.computeActiveState();
335
-
336
- if (active !== this.active) {
337
- this.active = active;
338
-
339
- if (this.registered) {
340
- this.registry?.setProps(this.portalKey, { active });
341
- }
342
- }
343
-
344
- return active;
345
- }
346
-
347
- /**
348
- * Explicit `checkState` wins; otherwise the mark derivation answers
349
- * "does the WHOLE selection carry the mark" via `api.marks.has`.
350
- */
351
- private computeActiveState(): boolean {
352
- if (typeof spec.checkState === 'function') {
353
- return Boolean(spec.checkState(window.getSelection(), this.api));
354
- }
355
-
356
- if (spec.mark !== undefined && this.api?.marks !== undefined) {
357
- return this.api.marks.has(spec.mark);
358
- }
359
-
360
- return false;
361
- }
362
-
363
- /**
364
- * Build the per-instance provider component: the consumer's component
365
- * rendered inside the {@link useInlineTool} context, so nested
366
- * swatches/popovers reach the tool without prop-drilling.
367
- */
368
- private createPortalComponent(): ComponentType<ReactInlineToolRenderProps<Config>> {
369
- const api = this.api;
370
- const mark = spec.mark;
371
- const Component = spec.component;
372
-
373
- const markOps: InlineToolMarkOps<State> | null = mark !== undefined && api?.marks !== undefined
374
- ? {
375
- has: (): boolean => api.marks.has(mark),
376
- toggle: (state?: State): boolean => api.marks.toggle(mark, state),
377
- apply: (state?: State): HTMLElement[] => api.marks.apply(mark, state),
378
- remove: (): HTMLElement[] => api.marks.remove(mark),
379
- read: (): MarkSnapshot | null => api.marks.read(mark),
380
- }
381
- : null;
382
-
383
- const PortalComponent = (props: ReactInlineToolRenderProps<Config>): ReactElement => {
384
- const handle = useMemo<InlineToolHandle>(
385
- () => ({
386
- active: props.active,
387
- config: props.config,
388
- api,
389
- mark: markOps as InlineToolMarkOps | null,
390
- }),
391
- [props.active, props.config]
392
- );
393
-
394
- return (
395
- <InlineToolContext.Provider value={handle}>
396
- <Component active={props.active} config={props.config} />
397
- </InlineToolContext.Provider>
398
- );
399
- };
400
-
401
- PortalComponent.displayName = `BlokInlineTool(${this.toolName})`;
402
-
403
- return PortalComponent;
404
- }
405
- };
406
- }
package/src/deep-equal.ts DELETED
@@ -1,7 +0,0 @@
1
- /**
2
- * Re-export of the framework-agnostic deep equality used to dedupe reactive
3
- * `data` updates. The implementation now lives in `src/shared/deep-equal.ts`
4
- * (shared with the Angular adapter); this module is kept for back-compat with
5
- * existing `./deep-equal` importers in the React adapter.
6
- */
7
- export { deepEqual } from '@bloklabs/core/adapters';
package/src/holder-map.ts DELETED
@@ -1,17 +0,0 @@
1
- /**
2
- * Module-level WeakMap associating editor instances with their detached holder divs.
3
- * WeakMap ensures no memory leaks — when the editor is garbage collected, the holder reference is released.
4
- */
5
- const holders = new WeakMap<WeakKey, HTMLDivElement>();
6
-
7
- export function setHolder(editor: WeakKey, holder: HTMLDivElement): void {
8
- holders.set(editor, holder);
9
- }
10
-
11
- export function getHolder(editor: WeakKey): HTMLDivElement | undefined {
12
- return holders.get(editor);
13
- }
14
-
15
- export function removeHolder(editor: WeakKey): void {
16
- holders.delete(editor);
17
- }
package/src/index.ts DELETED
@@ -1,62 +0,0 @@
1
- export { useBlok } from './useBlok';
2
- // Host-i18n bridge (#41): re-export the direction/normalization helpers so a
3
- // host that owns its locale state can compute `dir` and validate tags without
4
- // deep-importing core. `<BlokEditor locale>` pairs with these.
5
- export { getDirection, normalizeLocale } from '@bloklabs/core/locales';
6
- export { BlokContent } from './BlokContent';
7
- export { BlokEditor } from './BlokEditor';
8
- export { useBlokHandle } from './useBlokHandle';
9
- export type { BlokEditorHandle } from './useBlokHandle';
10
- export { BlokProvider, useBlokDefaults } from './provide-blok';
11
- export { useBlokInstance } from './blok-instance-context';
12
- export type { UseBlokConfig, BlokContentProps } from './types';
13
- // The config/DOM prop split `BlokEditor` applies: a key not in this list is
14
- // spread onto the container div instead of routing into the editor config.
15
- // Hosts that filter props themselves must read it from here, not copy it.
16
- export { USE_BLOK_CONFIG_KEYS } from './config-keys';
17
- export type { BlokEditorProps } from './BlokEditor';
18
- export { useBlocks } from './useBlocks';
19
- export type { UseBlocksOptions } from './useBlocks';
20
- export { useBlokReady } from './useBlokReady';
21
- export type { UseBlokReadyOptions } from './useBlokReady';
22
- export { BlokView } from './BlokView';
23
- export type { BlokViewProps } from './BlokView';
24
- export { useBlokView } from './useBlokView';
25
- export { createReactBlock } from './createReactBlock';
26
- export type {
27
- BlockChildrenProps,
28
- BlockToolStatics,
29
- ChildAttributes,
30
- CreateReactBlockSpec,
31
- ReactBlockMountedContext,
32
- ReactBlockRenderProps,
33
- ReactBlockViewProps,
34
- ReactToolboxConfig,
35
- ReactToolboxConfigEntry,
36
- PropSchema,
37
- PropSchemaEntry,
38
- } from './createReactBlock';
39
- export { createReactInlineTool, useInlineTool } from './createReactInlineTool';
40
- export type {
41
- CreateReactInlineToolSpec,
42
- ReactInlineToolRenderProps,
43
- InlineToolHandle,
44
- InlineToolMarkOps,
45
- } from './createReactInlineTool';
46
- export {
47
- createBlockPortalRegistry,
48
- BLOK_PORTAL_REGISTRY_CONFIG_KEY,
49
- BLOK_TOOL_NAME_CONFIG_KEY,
50
- } from './block-portal-registry';
51
- export type { BlockPortalRegistry, BlockPortalEntry } from './block-portal-registry';
52
- export { BlockPortalHost } from './BlockPortalHost';
53
- export type { BlockPortalHostProps } from './BlockPortalHost';
54
- export type {
55
- BlockNode,
56
- CaretTarget,
57
- InsertSpec,
58
- TreeInsertSpec,
59
- InsertPosition,
60
- MoveTarget,
61
- UseBlocksApi,
62
- } from './blocks-snapshot';
@@ -1,154 +0,0 @@
1
- /**
2
- * Live tool-config functions: the reason a consumer should never have to freeze
3
- * callback identities (`useState(() => ...)`) or recreate the editor to get
4
- * fresh closures into tool configs.
5
- *
6
- * Core snapshots each tool's config when a block instance is constructed
7
- * (`ToolClass.settings` builds a merged copy), so a function passed in `tools`
8
- * at editor construction would otherwise be pinned forever — consumers worked
9
- * around it by freezing identities and smuggling live values through external
10
- * stores. Instead, `useBlok` hands core STABLE wrapper functions that delegate
11
- * to whatever closure the LATEST render passed at the same config path. New
12
- * render → new closure → the wrapper calls it; the editor is never recreated.
13
- */
14
-
15
- type AnyFunction = (...args: unknown[]) => unknown;
16
-
17
- const isPlainObject = (value: unknown): value is Record<string, unknown> => {
18
- if (typeof value !== 'object' || value === null) {
19
- return false;
20
- }
21
-
22
- const proto: unknown = Object.getPrototypeOf(value);
23
-
24
- return proto === Object.prototype || proto === null;
25
- };
26
-
27
- /** Walk `root` down `path`, returning undefined the moment the shape diverges. */
28
- const resolveAtPath = (root: unknown, path: readonly (string | number)[]): unknown =>
29
- path.reduce<unknown>((node, key) => {
30
- if (Array.isArray(node)) {
31
- return typeof key === 'number' ? node[key] : undefined;
32
- }
33
-
34
- if (isPlainObject(node)) {
35
- return node[key];
36
- }
37
-
38
- return undefined;
39
- }, root);
40
-
41
- /**
42
- * Build the component-facing `config` for a react block from the LATEST render's
43
- * raw config, preferring the stable live wrapper from `wrappedConfig` wherever
44
- * both sides hold a function at the same path. The result carries the newest
45
- * non-function VALUES while keeping function identities stable across renders
46
- * (wrappers already delegate to the latest closure), so a deep-equal dedupe of
47
- * successive results only fires when a value genuinely changed.
48
- */
49
- export const buildLiveBlockConfig = (
50
- wrappedConfig: unknown,
51
- latestConfig: unknown
52
- ): Record<string, unknown> => {
53
- const merge = (latest: unknown, wrapped: unknown): unknown => {
54
- if (typeof latest === 'function') {
55
- return typeof wrapped === 'function' ? wrapped : latest;
56
- }
57
-
58
- if (Array.isArray(latest)) {
59
- const wrappedArray = Array.isArray(wrapped) ? wrapped : [];
60
-
61
- return latest.map((item, index) => merge(item, wrappedArray[index]));
62
- }
63
-
64
- if (isPlainObject(latest)) {
65
- const wrappedObject = isPlainObject(wrapped) ? wrapped : {};
66
- const result: Record<string, unknown> = {};
67
-
68
- for (const [key, value] of Object.entries(latest)) {
69
- result[key] = merge(value, wrappedObject[key]);
70
- }
71
-
72
- return result;
73
- }
74
-
75
- return latest;
76
- };
77
-
78
- const merged = merge(latestConfig, wrappedConfig);
79
-
80
- return isPlainObject(merged) ? merged : {};
81
- };
82
-
83
- /**
84
- * Return a copy of `tools` where every function inside a tool's `config` is
85
- * replaced by a stable wrapper that calls the function found at the same path
86
- * in `getLatestTools()` (falling back to the construction-time original when
87
- * the latest config no longer carries one). Tool classes and non-function
88
- * values pass through untouched; the consumer's objects are never mutated.
89
- */
90
- export const bindLiveToolConfigFunctions = (tools: unknown, getLatestTools: () => unknown): unknown => {
91
- if (!isPlainObject(tools)) {
92
- return tools;
93
- }
94
-
95
- const result: Record<string, unknown> = {};
96
-
97
- for (const [name, entry] of Object.entries(tools)) {
98
- if (!isPlainObject(entry) || !isPlainObject(entry.config)) {
99
- result[name] = entry;
100
-
101
- continue;
102
- }
103
-
104
- const seen = new WeakSet();
105
-
106
- const wrapNode = (node: unknown, path: readonly (string | number)[]): unknown => {
107
- if (typeof node === 'function') {
108
- const original = node as AnyFunction;
109
-
110
- return function liveConfigFunction(this: unknown, ...args: unknown[]): unknown {
111
- const latestTools = getLatestTools();
112
- const latestEntry = isPlainObject(latestTools) ? latestTools[name] : undefined;
113
- const latestConfig = isPlainObject(latestEntry) ? latestEntry.config : undefined;
114
- const latest = resolveAtPath(latestConfig, path);
115
- const target = typeof latest === 'function' ? (latest as AnyFunction) : original;
116
-
117
- // Preserve the call-site `this` (config functions may be invoked as
118
- // methods of their config object).
119
- return Reflect.apply(target, this, args);
120
- };
121
- }
122
-
123
- if (Array.isArray(node)) {
124
- if (seen.has(node)) {
125
- return node;
126
- }
127
- seen.add(node);
128
-
129
- return node.map((item, index) => wrapNode(item, [...path, index]));
130
- }
131
-
132
- if (isPlainObject(node)) {
133
- if (seen.has(node)) {
134
- return node;
135
- }
136
- seen.add(node);
137
-
138
- const wrapped: Record<string, unknown> = {};
139
-
140
- for (const [key, value] of Object.entries(node)) {
141
- wrapped[key] = wrapNode(value, [...path, key]);
142
- }
143
-
144
- return wrapped;
145
- }
146
-
147
- return node;
148
- };
149
-
150
- result[name] = { ...entry, config: wrapNode(entry.config, []) };
151
- }
152
-
153
- return result;
154
- };