import { createElement, Fragment, type ComponentType, type ReactNode, } from "react"; import type { ExtensionBridge, ExtensionSearchFieldOverride, ExtensionSearchSchemaOverrides, ExtensionTabDefinition, ExtensionTabSlots, ExtensionViewState, GeneralExtrasSlotProps, PreviewSlotProps, SectionComponentProps, StatsSlotProps, } from "./extensionTypes"; import type { BaseTabProps } from "./tabs/types"; import type { TabId } from "./types"; import { ExtensionSlotBoundary } from "./components/ExtensionSlotBoundary"; const MAX_TABS = 24; const MAX_SEARCH_OVERRIDES = 200; const MAX_ID_LENGTH = 120; const MAX_LABEL_LENGTH = 200; const MAX_DESCRIPTION_LENGTH = 1000; const MAX_KEYWORDS = 24; const MAX_KEYWORD_LENGTH = 100; const MAX_VIEW_STATE_KEYS = 100; type ExtensionSlotHost = Record; type RegisterExtensionBridgeOptions = { host: ExtensionSlotHost; slotsKey: string; appliedKey: string; bridge: unknown; }; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function optionalString(value: unknown, maxLength: number): string | undefined { if (typeof value !== "string") return undefined; const normalized = value.trim(); return normalized && normalized.length <= maxLength ? normalized : undefined; } function componentSlot( value: unknown, ): ComponentType | undefined { return typeof value === "function" ? (value as ComponentType) : undefined; } function normalizeTab(value: unknown): ExtensionTabDefinition | null { if (!isRecord(value)) return null; const id = optionalString(value.id, MAX_ID_LENGTH); const label = optionalString(value.label, MAX_LABEL_LENGTH); if (!id || !label) return null; return { id: id as TabId, label, Icon: componentSlot<{ className?: string }>(value.Icon), }; } function normalizeRequiresToggle( value: unknown, ): ExtensionSearchFieldOverride["requiresToggle"] { if (!isRecord(value)) return undefined; const settingKey = optionalString(value.settingKey, MAX_ID_LENGTH); const toggleFieldId = optionalString(value.toggleFieldId, MAX_ID_LENGTH); if (!settingKey || !toggleFieldId) return undefined; const enabledValue = value.enabledValue; const validEnabledValue = enabledValue === undefined || typeof enabledValue === "string" || typeof enabledValue === "number" || typeof enabledValue === "boolean"; const operator = ["equals", "notEquals", "greaterThan"].includes( String(value.operator), ) ? (value.operator as "equals" | "notEquals" | "greaterThan") : undefined; return { settingKey, toggleFieldId, enabledValue: validEnabledValue ? enabledValue : undefined, operator, message: optionalString(value.message, MAX_DESCRIPTION_LENGTH), }; } function normalizeSearchOverride( value: unknown, ): ExtensionSearchFieldOverride | null { if (!isRecord(value)) return null; const tabId = optionalString(value.tabId, MAX_ID_LENGTH); const keywords = Array.isArray(value.keywords) ? value.keywords .slice(0, MAX_KEYWORDS) .map((keyword) => optionalString(keyword, MAX_KEYWORD_LENGTH)) .filter((keyword): keyword is string => Boolean(keyword)) : undefined; return { label: optionalString(value.label, MAX_LABEL_LENGTH), description: optionalString(value.description, MAX_DESCRIPTION_LENGTH), tabId: tabId as TabId | undefined, tabLabel: optionalString(value.tabLabel, MAX_LABEL_LENGTH), section: optionalString(value.section, MAX_LABEL_LENGTH), subTab: optionalString(value.subTab, MAX_ID_LENGTH), keywords, requiresToggle: normalizeRequiresToggle(value.requiresToggle), isExtended: value.isExtended === true ? true : undefined, }; } function normalizeSearchSchemaOverrides( value: unknown, ): ExtensionSearchSchemaOverrides | undefined { if (!isRecord(value)) return undefined; const entries = Object.entries(value) .slice(0, MAX_SEARCH_OVERRIDES) .map(([rawId, rawOverride]) => { const id = optionalString(rawId, MAX_ID_LENGTH); const override = normalizeSearchOverride(rawOverride); return id && override ? ([id, override] as const) : null; }) .filter( (entry): entry is readonly [string, ExtensionSearchFieldOverride] => entry !== null, ); return Object.fromEntries(entries); } function normalizeViewState(value: unknown): ExtensionViewState | undefined { if (!isRecord(value)) return undefined; const entries = Object.entries(value) .slice(0, MAX_VIEW_STATE_KEYS) .filter(([, item]) => ["string", "number", "boolean", "undefined"].includes(typeof item), ); return Object.fromEntries(entries) as ExtensionViewState; } function normalizeTabSlots(value: unknown): ExtensionTabSlots | undefined { if (!isRecord(value)) return undefined; return { general: componentSlot(value.general), chips: componentSlot(value.chips), advanced: componentSlot(value.advanced), }; } /** Normalize optional runtime extension data before the admin shell consumes it. */ export function normalizeExtensionBridge(value: unknown): ExtensionBridge { if (!isRecord(value)) return {}; const tabs = Array.isArray(value.tabs) ? value.tabs .slice(0, MAX_TABS) .map(normalizeTab) .filter((tab): tab is ExtensionTabDefinition => tab !== null) : undefined; return { tabs, tabSlots: normalizeTabSlots(value.tabSlots), previewSlot: componentSlot(value.previewSlot), statsSlot: componentSlot(value.statsSlot), typographySection: componentSlot( value.typographySection, ), tooltipsSection: componentSlot( value.tooltipsSection, ), animationsSection: componentSlot(value.animationsSection), accessProvider: componentSlot(value.accessProvider), statusBadge: componentSlot(value.statusBadge), heroDescription: optionalString( value.heroDescription, MAX_DESCRIPTION_LENGTH, ), searchSchemaOverrides: normalizeSearchSchemaOverrides( value.searchSchemaOverrides, ), initialViewState: normalizeViewState(value.initialViewState), syncViewState: typeof value.syncViewState === "function" ? (value.syncViewState as NonNullable) : undefined, renderExtensionTab: typeof value.renderExtensionTab === "function" ? (value.renderExtensionTab as NonNullable< ExtensionBridge["renderExtensionTab"] >) : undefined, }; } export function composeSlotComponents( existing?: ComponentType, incoming?: ComponentType, ): ComponentType | undefined { if (!existing) return incoming; if (!incoming) return existing; return function ComposedExtensionSlot(props: Props) { return createElement( Fragment, null, createElement( ExtensionSlotBoundary, { key: "existing", resetKey: existing }, createElement(existing, props), ), createElement( ExtensionSlotBoundary, { key: "incoming", resetKey: incoming }, createElement(incoming, props), ), ); }; } export function composeSlotProviders( existing?: ComponentType, incoming?: ComponentType, ): ComponentType | undefined { if (!existing) return incoming; if (!incoming) return existing; return function ComposedExtensionProvider(props: Props) { const incomingTree = createElement( ExtensionSlotBoundary, { resetKey: incoming, fallback: props.children }, createElement(incoming, props, props.children), ); return createElement( ExtensionSlotBoundary, { resetKey: existing, fallback: incomingTree }, createElement(existing, props, incomingTree), ); }; } function mergeTabs( existing: ExtensionTabDefinition[] = [], incoming: ExtensionTabDefinition[] = [], ): ExtensionTabDefinition[] { const tabs = new Map(); for (const tab of [...existing, ...incoming]) tabs.set(tab.id, tab); return Array.from(tabs.values()).slice(0, MAX_TABS); } function composeTabSlots( existing: ExtensionTabSlots = {}, incoming: ExtensionTabSlots = {}, ): ExtensionTabSlots { return { general: composeSlotComponents(existing.general, incoming.general), chips: composeSlotComponents(existing.chips, incoming.chips), advanced: composeSlotComponents(existing.advanced, incoming.advanced), }; } function composeViewStateSync( existing?: ExtensionBridge["syncViewState"], incoming?: ExtensionBridge["syncViewState"], ): ExtensionBridge["syncViewState"] { if (!existing) return incoming; if (!incoming) return existing; return (args) => { for (const sync of [existing, incoming]) { try { sync(args); } catch (error) { console.error( "[b3-swatches] Extension view-state synchronization failed.", error, ); } } }; } type ExtensionTabRenderer = NonNullable; function ExtensionTabRendererContribution({ renderer, args, }: { renderer: ExtensionTabRenderer; args: Parameters[0]; }) { return createElement(Fragment, null, renderer(args)); } function composeTabRenderers( existing?: ExtensionBridge["renderExtensionTab"], incoming?: ExtensionBridge["renderExtensionTab"], ): ExtensionBridge["renderExtensionTab"] { if (!existing) return incoming; if (!incoming) return existing; return (args) => createElement( Fragment, null, createElement( ExtensionSlotBoundary, { key: "existing", resetKey: existing }, createElement(ExtensionTabRendererContribution, { renderer: existing, args, }), ), createElement( ExtensionSlotBoundary, { key: "incoming", resetKey: incoming }, createElement(ExtensionTabRendererContribution, { renderer: incoming, args, }), ), ); } /** Merge extension bundles additively while retaining registration order. */ export function mergeExtensionBridges( existingValue: unknown, incomingValue: unknown, ): ExtensionBridge { const existing = normalizeExtensionBridge(existingValue); const incoming = normalizeExtensionBridge(incomingValue); return { tabs: mergeTabs(existing.tabs, incoming.tabs), tabSlots: composeTabSlots(existing.tabSlots, incoming.tabSlots), previewSlot: composeSlotComponents( existing.previewSlot, incoming.previewSlot, ), statsSlot: composeSlotComponents(existing.statsSlot, incoming.statsSlot), typographySection: composeSlotComponents( existing.typographySection, incoming.typographySection, ), tooltipsSection: composeSlotComponents( existing.tooltipsSection, incoming.tooltipsSection, ), animationsSection: composeSlotComponents( existing.animationsSection, incoming.animationsSection, ), accessProvider: composeSlotProviders( existing.accessProvider, incoming.accessProvider, ), statusBadge: composeSlotComponents( existing.statusBadge, incoming.statusBadge, ), heroDescription: incoming.heroDescription ?? existing.heroDescription, searchSchemaOverrides: normalizeSearchSchemaOverrides({ ...(existing.searchSchemaOverrides ?? {}), ...(incoming.searchSchemaOverrides ?? {}), }), initialViewState: normalizeViewState({ ...(existing.initialViewState ?? {}), ...(incoming.initialViewState ?? {}), }), syncViewState: composeViewStateSync( existing.syncViewState, incoming.syncViewState, ), renderExtensionTab: composeTabRenderers( existing.renderExtensionTab, incoming.renderExtensionTab, ), }; } /** Register one extension bundle at most once without replacing earlier slots. */ export function registerExtensionBridgeOnce({ host, slotsKey, appliedKey, bridge, }: RegisterExtensionBridgeOptions): ExtensionBridge { if (host[appliedKey] === true) { return normalizeExtensionBridge(host[slotsKey]); } const merged = mergeExtensionBridges(host[slotsKey], bridge); host[slotsKey] = merged; host[appliedKey] = true; return merged; }