@salesforce/ui-bundle-template-app-react-template-b2e 11.55.1 → 11.56.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.
Files changed (42) hide show
  1. package/dist/CHANGELOG.md +8 -0
  2. package/dist/force-app/main/default/uiBundles/reactinternalapp/package.json +4 -4
  3. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/README.md +235 -0
  4. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/apiUtils.ts +56 -0
  5. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/orgApiVersionService.ts +26 -0
  6. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/searchableContentTypesService.ts +116 -0
  7. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/channelResolver.ts +40 -0
  8. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/cmsQueryFragment.ts +97 -0
  9. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/contentTypeSessionCache.ts +134 -0
  10. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/contentTypeUtils.ts +38 -0
  11. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/hooks/useSearchableContentTypes.ts +128 -0
  12. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/index.ts +54 -0
  13. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/parseResponse.ts +65 -0
  14. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/searchChannel.ts +15 -0
  15. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/types.ts +66 -0
  16. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/registry.ts +38 -0
  17. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/index.ts +19 -0
  18. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/parseResponse.ts +56 -0
  19. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/queryFragment.ts +141 -0
  20. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/types.ts +101 -0
  21. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/api/searchService.ts +110 -45
  22. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/GlobalSearchBox.tsx +98 -0
  23. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/MergedSearchResults.tsx +34 -17
  24. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/Search.tsx +31 -13
  25. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/SearchResults.tsx +17 -11
  26. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/SourceSection.tsx +9 -4
  27. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/controls/ScopeSelector.tsx +50 -6
  28. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/results/CmsResultRow.tsx +101 -0
  29. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/results/resolveResultRenderer.ts +49 -0
  30. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/config.json +7 -2
  31. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/constants.ts +8 -0
  32. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/hooks/useSearch.ts +313 -50
  33. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/index.ts +26 -1
  34. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/queryBuilder.ts +62 -118
  35. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/types.ts +74 -5
  36. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/pages/Home.tsx +3 -42
  37. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/routes.tsx +12 -5
  38. package/dist/force-app/main/default/uiBundles/reactinternalapp/tsconfig.tsbuildinfo +1 -1
  39. package/dist/package-lock.json +2 -2
  40. package/dist/package.json +1 -1
  41. package/package.json +4 -2
  42. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/pages/AccountSearch.tsx +0 -25
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `sessionStorage`-backed persistence for discovered CMS content types.
3
+ *
4
+ * Discovery is otherwise ephemeral: the in-memory module cache in
5
+ * {@link useSearchableContentTypes} is wiped on a hard reload, and content
6
+ * types are only re-derived once a CMS search actually returns a channel id.
7
+ * Persisting them per channel lets the scope dropdown keep showing the
8
+ * discovered types across any number of reloads, and lets the discovery hook
9
+ * rehydrate them synchronously (no re-fetch) on mount.
10
+ *
11
+ * Two things are persisted:
12
+ * - `types:<channelId>` — the validated `{ fqn, label }[]` for that
13
+ * channel. Keyed by the channel id, which is channel-specific already.
14
+ * - `last-channel-id:<uiBundleId>` — the most recently discovered channel id
15
+ * FOR THAT UIBundle, so the hook can seed its discovery state on mount
16
+ * before any CMS result arrives. Keyed by UIBundle id (not origin-wide):
17
+ * content types are channel-specific, so a second UIBundle opened in the
18
+ * same session must NOT inherit the first bundle's channel/types.
19
+ *
20
+ * Every access is wrapped: `sessionStorage` can be absent (SSR), throw
21
+ * (private-mode / disabled cookies), or hold corrupt JSON. On any failure we
22
+ * degrade to "no cache" rather than surfacing an error — persistence is an
23
+ * enhancement, never a hard dependency.
24
+ */
25
+
26
+ import { isValidCmsFqn, type DiscoveredContentType } from "./contentTypeUtils";
27
+ import { CHANNEL_ID_PATTERN } from "./api/searchableContentTypesService";
28
+ import { UI_BUNDLE_ID_PATTERN } from "./searchChannel";
29
+
30
+ const KEY_PREFIX = "cms-search:content-types:";
31
+ const TYPES_KEY = (channelId: string) => `${KEY_PREFIX}types:${channelId}`;
32
+ const LAST_CHANNEL_KEY = (uiBundleId: string) => `${KEY_PREFIX}last-channel-id:${uiBundleId}`;
33
+
34
+ /** Returns the `sessionStorage` object, or `null` when it is unavailable/throws. */
35
+ function storage(): Storage | null {
36
+ try {
37
+ if (typeof sessionStorage === "undefined") return null;
38
+ return sessionStorage;
39
+ } catch {
40
+ // Accessing `sessionStorage` can throw when storage is disabled.
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /** Narrows an unknown parsed value to a clean `DiscoveredContentType[]`. */
46
+ function sanitize(parsed: unknown): DiscoveredContentType[] {
47
+ if (!Array.isArray(parsed)) return [];
48
+ const out: DiscoveredContentType[] = [];
49
+ const seen = new Set<string>();
50
+ for (const entry of parsed) {
51
+ if (!entry || typeof entry !== "object") continue;
52
+ const { fqn, label } = entry as { fqn?: unknown; label?: unknown };
53
+ // Re-validate the FQN on read: cached data is untrusted input and its
54
+ // values flow straight into the query variable and rendered labels.
55
+ if (typeof fqn !== "string" || !isValidCmsFqn(fqn) || seen.has(fqn)) continue;
56
+ if (typeof label !== "string" || label.length === 0) continue;
57
+ seen.add(fqn);
58
+ out.push({ fqn, label });
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /**
64
+ * Reads the persisted content types for `channelId`. Returns `null` when there
65
+ * is no (valid) cache entry, so callers can distinguish "never persisted" from
66
+ * "persisted as empty".
67
+ */
68
+ export function readCachedContentTypes(channelId: string): DiscoveredContentType[] | null {
69
+ const store = storage();
70
+ if (!store || !CHANNEL_ID_PATTERN.test(channelId)) return null;
71
+ let raw: string | null;
72
+ try {
73
+ raw = store.getItem(TYPES_KEY(channelId));
74
+ } catch {
75
+ return null;
76
+ }
77
+ if (raw == null) return null;
78
+ try {
79
+ return sanitize(JSON.parse(raw));
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Persists `types` for `channelId`. No-ops (never throws) when storage is
87
+ * unavailable or the id is malformed. The last-discovered-channel pointer is
88
+ * written separately via {@link writeLastChannelId}, which needs the UIBundle
89
+ * id this channel belongs to.
90
+ */
91
+ export function writeCachedContentTypes(channelId: string, types: DiscoveredContentType[]): void {
92
+ const store = storage();
93
+ if (!store || !CHANNEL_ID_PATTERN.test(channelId)) return;
94
+ try {
95
+ store.setItem(TYPES_KEY(channelId), JSON.stringify(types));
96
+ } catch {
97
+ // Quota exceeded / disabled storage — persistence is best-effort.
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Records `channelId` as the last discovered channel FOR `uiBundleId`. Keyed by
103
+ * UIBundle id so a different bundle opened in the same session never inherits
104
+ * this channel. No-ops (never throws) when storage is unavailable or either id
105
+ * is malformed.
106
+ */
107
+ export function writeLastChannelId(uiBundleId: string, channelId: string): void {
108
+ const store = storage();
109
+ if (!store || !UI_BUNDLE_ID_PATTERN.test(uiBundleId) || !CHANNEL_ID_PATTERN.test(channelId)) {
110
+ return;
111
+ }
112
+ try {
113
+ store.setItem(LAST_CHANNEL_KEY(uiBundleId), channelId);
114
+ } catch {
115
+ // Quota exceeded / disabled storage — persistence is best-effort.
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Returns the last discovered channel id for `uiBundleId` (validated shape), or
121
+ * `null`. Lets the discovery hook seed its channel on mount so THIS bundle's
122
+ * cached types render immediately, before any CMS search result comes back.
123
+ */
124
+ export function readLastChannelId(uiBundleId: string): string | null {
125
+ const store = storage();
126
+ if (!store || !UI_BUNDLE_ID_PATTERN.test(uiBundleId)) return null;
127
+ let raw: string | null;
128
+ try {
129
+ raw = store.getItem(LAST_CHANNEL_KEY(uiBundleId));
130
+ } catch {
131
+ return null;
132
+ }
133
+ return raw != null && CHANNEL_ID_PATTERN.test(raw) ? raw : null;
134
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * CMS content-type FQN helpers.
3
+ *
4
+ * `isValidCmsFqn` / `CMS_FQN_PATTERN` guard every FQN before it enters query
5
+ * state, the GraphQL variable, or a rendered label. `formatContentTypeLabel`
6
+ * turns an FQN into a human-readable dropdown/section label.
7
+ */
8
+
9
+ /**
10
+ * Formats a CMS content type FQN into a human-readable label.
11
+ * Strips "sfdc_cms__" prefix, splits camelCase, title-cases each word.
12
+ *
13
+ * Examples:
14
+ * "sfdc_cms__blogPost" → "Blog Post"
15
+ * "sfdc_cms__news" → "News"
16
+ * "sfdc_cms__document" → "Document"
17
+ * "sfdc_cms__pressRelease" → "Press Release"
18
+ */
19
+ export function formatContentTypeLabel(fqn: string): string {
20
+ const stripped = fqn.replace(/^sfdc_cms__/, "");
21
+ const words = stripped.replace(/([a-z])([A-Z])/g, "$1 $2");
22
+ return words.charAt(0).toUpperCase() + words.slice(1);
23
+ }
24
+
25
+ export const CMS_FQN_PATTERN = /^sfdc_cms__[a-zA-Z][a-zA-Z0-9]{0,39}$/;
26
+
27
+ export function isValidCmsFqn(fqn: string): boolean {
28
+ return CMS_FQN_PATTERN.test(fqn);
29
+ }
30
+
31
+ /**
32
+ * A discovered CMS content type: its FQN (`sfdc_cms__…`, used as the scope value
33
+ * and query filter) and its server-provided display `label` (e.g. "News").
34
+ */
35
+ export interface DiscoveredContentType {
36
+ fqn: string;
37
+ label: string;
38
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Session-cache hook for a CMS channel's searchable content types.
3
+ *
4
+ * A module-level `Map` cache + a `Map` of in-flight promises, keyed by
5
+ * `channelId`, so the Connect endpoint is hit once per channel per session and
6
+ * concurrent callers share one request. A `null` channelId short-circuits (no
7
+ * fetch) — discovery only runs after a CMS result yields a channel id.
8
+ */
9
+
10
+ import { useState, useEffect, useRef } from "react";
11
+ import { fetchSearchableContentTypes } from "../api/searchableContentTypesService";
12
+ import type { DiscoveredContentType } from "../contentTypeUtils";
13
+ import { readCachedContentTypes, writeCachedContentTypes } from "../contentTypeSessionCache";
14
+
15
+ /** Session cache: channelId → discovered content types. Populated once, reused after. */
16
+ const cache = new Map<string, DiscoveredContentType[]>();
17
+ /** In-flight dedupe: channelId → the pending fetch promise. */
18
+ const inflight = new Map<string, Promise<DiscoveredContentType[]>>();
19
+
20
+ /**
21
+ * Returns any already-known content types for `channelId` WITHOUT fetching:
22
+ * the in-memory module cache first, then the `sessionStorage` cache (which
23
+ * survives a hard reload). The `sessionStorage` hit is promoted into the module
24
+ * cache so subsequent lookups stay in memory. Returns `null` when neither holds
25
+ * the channel, so callers know a network fetch is still required.
26
+ */
27
+ function peekCachedContentTypes(channelId: string): DiscoveredContentType[] | null {
28
+ const inMemory = cache.get(channelId);
29
+ if (inMemory) return inMemory;
30
+ const persisted = readCachedContentTypes(channelId);
31
+ if (persisted) {
32
+ cache.set(channelId, persisted);
33
+ return persisted;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Fetches (once per session) and caches the searchable content types for a
40
+ * channel. Concurrent callers for the same channel share one request. Results
41
+ * are cached in memory AND persisted to `sessionStorage` so a hard reload can
42
+ * rehydrate them without a re-fetch.
43
+ */
44
+ export function getSearchableContentTypes(channelId: string): Promise<DiscoveredContentType[]> {
45
+ const cached = peekCachedContentTypes(channelId);
46
+ if (cached) return Promise.resolve(cached);
47
+
48
+ const pending = inflight.get(channelId);
49
+ if (pending) return pending;
50
+
51
+ const promise = (async () => {
52
+ try {
53
+ const result = await fetchSearchableContentTypes(channelId);
54
+ cache.set(channelId, result);
55
+ // Persist so the scope dropdown keeps the types across hard reloads.
56
+ writeCachedContentTypes(channelId, result);
57
+ return result;
58
+ } finally {
59
+ inflight.delete(channelId);
60
+ }
61
+ })();
62
+ inflight.set(channelId, promise);
63
+ return promise;
64
+ }
65
+
66
+ export interface UseSearchableContentTypesResult {
67
+ /** Discovered, validated content types (FQN + label; empty until resolved). */
68
+ contentTypes: DiscoveredContentType[];
69
+ loading: boolean;
70
+ error: string | null;
71
+ }
72
+
73
+ /**
74
+ * React hook wrapping {@link getSearchableContentTypes}. Re-runs only when the
75
+ * `channelId` string changes; a `null` id yields an empty, non-loading state.
76
+ */
77
+ export function useSearchableContentTypes(
78
+ channelId: string | null,
79
+ ): UseSearchableContentTypesResult {
80
+ // Seed synchronously from the in-memory OR sessionStorage cache so a hard
81
+ // reload shows the persisted content types on the first render (before any
82
+ // CMS search result comes back), with no fetch and no loading flash.
83
+ const [state, setState] = useState<UseSearchableContentTypesResult>(() => {
84
+ const seeded = channelId ? peekCachedContentTypes(channelId) : null;
85
+ return {
86
+ contentTypes: seeded ?? [],
87
+ loading: channelId != null && seeded == null,
88
+ error: null,
89
+ };
90
+ });
91
+ const isCancelled = useRef(false);
92
+
93
+ useEffect(() => {
94
+ isCancelled.current = false;
95
+
96
+ if (channelId == null) {
97
+ setState({ contentTypes: [], loading: false, error: null });
98
+ return;
99
+ }
100
+
101
+ const cached = peekCachedContentTypes(channelId);
102
+ if (cached) {
103
+ setState({ contentTypes: cached, loading: false, error: null });
104
+ return;
105
+ }
106
+
107
+ setState((s) => ({ ...s, loading: true, error: null }));
108
+ getSearchableContentTypes(channelId)
109
+ .then((contentTypes) => {
110
+ if (isCancelled.current) return;
111
+ setState({ contentTypes, loading: false, error: null });
112
+ })
113
+ .catch((err) => {
114
+ if (isCancelled.current) return;
115
+ setState({
116
+ contentTypes: [],
117
+ loading: false,
118
+ error: err instanceof Error ? err.message : String(err),
119
+ });
120
+ });
121
+
122
+ return () => {
123
+ isCancelled.current = true;
124
+ };
125
+ }, [channelId]);
126
+
127
+ return state;
128
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * CMS adapter — fronts the `managed_content` GraphQL block behind the
3
+ * kind-agnostic {@link SourceAdapter} contract.
4
+ *
5
+ * `buildRequest` is SYNCHRONOUS (the query builder is sync): it reads the
6
+ * already-resolved `request.uiBundleId` (the caller resolves the async
7
+ * `getUIBundleId()` upstream and threads it in), decodes `afterCursor` → offset,
8
+ * and passes `request.contentTypes` through to the fragment builder ONLY when
9
+ * non-empty (bootstrap omits them). `parseResponse` delegates to the CMS parser.
10
+ *
11
+ * The registry (`../registry.ts`) imports `cmsAdapter` from here by name.
12
+ */
13
+
14
+ import type { CmsSourceConfig } from "./types";
15
+ import type { SourceAdapter, SourceRequest, QueryFragmentContribution } from "../types";
16
+ import { buildCmsQueryFragment } from "./cmsQueryFragment";
17
+ import { parseCmsResponse } from "./parseResponse";
18
+ import { isValidCmsFqn } from "./contentTypeUtils";
19
+
20
+ /** Decodes an opaque forward cursor into a non-negative integer offset. */
21
+ function decodeOffset(afterCursor: string | undefined): number {
22
+ if (afterCursor == null) return 0;
23
+ const parsed = parseInt(afterCursor, 10);
24
+ if (!Number.isFinite(parsed) || parsed < 0) return 0;
25
+ return parsed;
26
+ }
27
+
28
+ /**
29
+ * Builds the CMS fragment contribution from a request. Content types are passed
30
+ * through only when non-empty AND every entry is a valid FQN (defence in depth —
31
+ * `useSearch` already validates before state).
32
+ */
33
+ function buildCmsRequest(request: SourceRequest<CmsSourceConfig>): QueryFragmentContribution {
34
+ const offset = decodeOffset(request.afterCursor);
35
+
36
+ const contentTypes =
37
+ Array.isArray(request.contentTypes) && request.contentTypes.length > 0
38
+ ? request.contentTypes.filter(isValidCmsFqn)
39
+ : null;
40
+
41
+ return buildCmsQueryFragment({
42
+ keyword: request.q,
43
+ uiBundleId: request.uiBundleId ?? "",
44
+ offset,
45
+ limit: request.pageSize,
46
+ contentTypes: contentTypes && contentTypes.length > 0 ? contentTypes : null,
47
+ });
48
+ }
49
+
50
+ export const cmsAdapter: SourceAdapter<CmsSourceConfig> = {
51
+ kind: "cms",
52
+ buildRequest: buildCmsRequest,
53
+ parseResponse: parseCmsResponse,
54
+ };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * CMS response parser: `root.managed_content.search.searchContentInChannels` →
3
+ * {@link SourceResult}, normalising CMS offset paging onto the common
4
+ * cursor-shaped {@link SourcePageInfo}.
5
+ *
6
+ * Partial-failure leniency: every level is guarded and the function returns
7
+ * `null` — never throws — when its subtree is absent or errored.
8
+ *
9
+ * Offset → pageInfo:
10
+ * - `totalCount = total`
11
+ * - `hasNextPage = offset + items.length < total`
12
+ * - `hasPreviousPage= offset > 0`
13
+ * - `endCursor = String(offset + pageSize)` (the caller's request page size)
14
+ * - `startCursor = String(offset)`
15
+ */
16
+
17
+ import type { CmsSourceConfig, CmsSearchResponse } from "./types";
18
+ import type { SourceResult } from "../../types";
19
+ import type { SourceRequest } from "../types";
20
+
21
+ /** Narrows an unknown value to a plain object (or `undefined`). */
22
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
23
+ return value != null && typeof value === "object"
24
+ ? (value as Record<string, unknown>)
25
+ : undefined;
26
+ }
27
+
28
+ /**
29
+ * Reads the CMS subtree from the full response `data` root and maps it to a
30
+ * {@link SourceResult}. Returns `null` when the subtree is absent/errored.
31
+ *
32
+ * `pageSize` for the cursor math is taken from the request (the page size we
33
+ * asked for), matching `buildRequest`'s `$cmsLimit = request.pageSize`, so
34
+ * next/prev advances the offset by exactly one page.
35
+ */
36
+ export function parseCmsResponse(
37
+ root: Record<string, unknown>,
38
+ request: SourceRequest<CmsSourceConfig>,
39
+ ): SourceResult | null {
40
+ const managedContent = asRecord(root?.managed_content);
41
+ const search = asRecord(managedContent?.search);
42
+ const raw = asRecord(search?.searchContentInChannels);
43
+ if (!raw) return null;
44
+
45
+ const response = raw as unknown as CmsSearchResponse;
46
+
47
+ const items = Array.isArray(response.items) ? response.items : [];
48
+ const total = typeof response.total === "number" ? response.total : null;
49
+ const offset = typeof response.offset === "number" ? response.offset : 0;
50
+ const pageSize = request.pageSize;
51
+
52
+ const hasNextPage = total != null ? offset + items.length < total : false;
53
+ const hasPreviousPage = offset > 0;
54
+
55
+ return {
56
+ nodes: items,
57
+ pageInfo: {
58
+ hasNextPage,
59
+ hasPreviousPage,
60
+ startCursor: String(offset),
61
+ endCursor: String(offset + pageSize),
62
+ },
63
+ totalCount: total,
64
+ };
65
+ }
@@ -0,0 +1,15 @@
1
+ import { getCurrentApp } from "@salesforce/platform-sdk";
2
+
3
+ /** Returns the UIBundle id for CMS search, resolved from the running app. */
4
+ export async function getUIBundleId(): Promise<string> {
5
+ const app = await getCurrentApp();
6
+ return app.identity?.bundleId ?? "";
7
+ }
8
+
9
+ /** Shape guard for a UIBundle record id (9YE + 12-15 alphanumerics). */
10
+ export const UI_BUNDLE_ID_PATTERN = /^9YE[a-zA-Z0-9]{12,15}$/;
11
+
12
+ /** True when `id` is a well-formed (9YE-shaped) UIBundle id. */
13
+ export function isConfiguredUIBundleId(id: string): boolean {
14
+ return UI_BUNDLE_ID_PATTERN.test(id.trim());
15
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * CMS source config + the response shapes returned by
3
+ * `managed_content.search.searchContentInChannels`.
4
+ */
5
+
6
+ export interface CmsChannelDetails {
7
+ id: string;
8
+ name: string;
9
+ type?: string;
10
+ }
11
+
12
+ export interface CmsChannelDeliveryDetails {
13
+ contentUrl?: string;
14
+ publishedDate?: string;
15
+ managedContentChannelDetails: CmsChannelDetails;
16
+ }
17
+
18
+ export interface CmsSearchItem {
19
+ managedContentId: string;
20
+ managedContentKey: string;
21
+ title: string;
22
+ contentType: string;
23
+ language?: string;
24
+ highlightedSnippet?: string;
25
+ managedContentChannelDeliveryDetails: CmsChannelDeliveryDetails[];
26
+ }
27
+
28
+ export interface CmsSearchResponse {
29
+ total: number;
30
+ offset: number;
31
+ pageSize: number;
32
+ items: CmsSearchItem[];
33
+ }
34
+
35
+ /**
36
+ * Configuration for one CMS source in a search experience.
37
+ *
38
+ * Deliberately minimal — unlike an SObject source (which hardcodes an object
39
+ * name, searchable/display fields, filters, etc.), a CMS source hardcodes
40
+ * nothing about the CMS data. The search channel id comes from
41
+ * `searchChannel.getUIBundleId()`; content types are discovered at runtime from
42
+ * the search results. This config only names the source and (optionally)
43
+ * describes how to build a result link.
44
+ */
45
+ export interface CmsSourceConfig {
46
+ /** Discriminator — selects the CMS runtime adapter. */
47
+ kind: "cms";
48
+ /**
49
+ * Stable identifier. Used as the scope key, the result-map key, and the
50
+ * prefix for CMS content-type scope entries (`"<key>:<fqn>"`). Must match
51
+ * `/^[A-Za-z_][A-Za-z0-9_]*$/`.
52
+ */
53
+ key: string;
54
+ /** Plural display label — dropdown entry + section header, e.g. "Content". */
55
+ label: string;
56
+ /** Singular label for the per-card source badge. Falls back to `label`. */
57
+ labelSingular?: string;
58
+ /**
59
+ * Optional URL pattern used by `CmsResultRow` to build a result link.
60
+ * Tokens: `:id` (managedContentId), `:key` (managedContentKey),
61
+ * `:contentType` (contentType). When omitted, the row falls back to
62
+ * `/content/:contentType/:id`.
63
+ */
64
+ routePattern?: string;
65
+ // NO channelId, NO content types, NO displayFields — all resolved at runtime.
66
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Adapter registry — maps a source `kind` to its runtime {@link SourceAdapter}.
3
+ *
4
+ * The core query builder and response parser call {@link getAdapter} for each
5
+ * source instead of hard-coding SObject logic, so a new backend is added purely
6
+ * by registering an adapter here (plus its `kind` in the `SourceConfig` union).
7
+ *
8
+ * The `cmsAdapter` is authored by the CMS implementer at `./cms` (exported as
9
+ * `cmsAdapter`); it is imported and registered here, never stubbed.
10
+ */
11
+
12
+ import type { SourceConfig } from "../types";
13
+ import type { SourceAdapter } from "./types";
14
+ import { sObjectAdapter } from "./sobject";
15
+ import { cmsAdapter } from "./cms";
16
+
17
+ /**
18
+ * Registry keyed by `SourceConfig["kind"]`. Each entry is the adapter narrowed
19
+ * to the matching config variant; `getAdapter` widens back to the base
20
+ * `SourceAdapter` for kind-agnostic callers.
21
+ */
22
+ const REGISTRY: { [K in SourceConfig["kind"]]: SourceAdapter<Extract<SourceConfig, { kind: K }>> } =
23
+ {
24
+ sobject: sObjectAdapter,
25
+ cms: cmsAdapter,
26
+ };
27
+
28
+ /**
29
+ * Returns the adapter registered for `kind`. Throws on an unknown kind (a
30
+ * config carrying a `kind` with no adapter is a programming error).
31
+ */
32
+ export function getAdapter(kind: SourceConfig["kind"]): SourceAdapter {
33
+ const adapter = REGISTRY[kind];
34
+ if (!adapter) {
35
+ throw new Error(`No search adapter registered for source kind "${kind}".`);
36
+ }
37
+ return adapter as SourceAdapter;
38
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * SObject adapter — the original uiapi GraphQL bridge, now behind the
3
+ * kind-agnostic {@link SourceAdapter} contract.
4
+ *
5
+ * `buildRequest` delegates to the extracted fragment builder and
6
+ * `parseResponse` to the extracted response parser. Both preserve the original
7
+ * behaviour byte-for-byte; this file is only the wiring.
8
+ */
9
+
10
+ import type { SObjectSourceConfig } from "../../types";
11
+ import type { SourceAdapter } from "../types";
12
+ import { buildSObjectFragment } from "./queryFragment";
13
+ import { parseSObjectResponse } from "./parseResponse";
14
+
15
+ export const sObjectAdapter: SourceAdapter<SObjectSourceConfig> = {
16
+ kind: "sobject",
17
+ buildRequest: buildSObjectFragment,
18
+ parseResponse: parseSObjectResponse,
19
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * SObject response parser.
3
+ *
4
+ * Extracted verbatim from `searchService.ts`: the `RawSourceResult` interface
5
+ * and the `edges → nodes / pageInfo / totalCount` mapping. Reads its subtree
6
+ * from `root.uiapi.query[source.key]` and returns `null` when the alias is
7
+ * absent (partial-failure leniency), never throwing.
8
+ */
9
+
10
+ import type { SObjectSourceConfig, SourceResult } from "../../types";
11
+ import type { SourceRequest } from "../types";
12
+
13
+ interface RawSourceResult {
14
+ edges?: Array<{ node?: unknown }> | null;
15
+ pageInfo?: {
16
+ hasNextPage?: boolean | null;
17
+ hasPreviousPage?: boolean | null;
18
+ startCursor?: string | null;
19
+ endCursor?: string | null;
20
+ } | null;
21
+ totalCount?: number | null;
22
+ }
23
+
24
+ /**
25
+ * Maps `root.uiapi.query[source.key]` (an `edges`/`pageInfo`/`totalCount`
26
+ * subtree) to a {@link SourceResult}. Returns `null` when the alias is missing
27
+ * from the response (e.g. a partial GraphQL error blanked this source only).
28
+ */
29
+ export function parseSObjectResponse(
30
+ root: Record<string, unknown>,
31
+ request: SourceRequest<SObjectSourceConfig>,
32
+ ): SourceResult | null {
33
+ const queryRoot = (root?.uiapi as Record<string, unknown> | undefined)?.query as
34
+ | Record<string, RawSourceResult | null | undefined>
35
+ | undefined;
36
+
37
+ const raw = queryRoot?.[request.source.key];
38
+ if (raw == null) return null;
39
+
40
+ const nodes = (raw.edges ?? [])
41
+ .map((edge) => edge?.node)
42
+ .filter((node): node is unknown => node != null);
43
+
44
+ return {
45
+ nodes,
46
+ pageInfo: raw.pageInfo
47
+ ? {
48
+ hasNextPage: raw.pageInfo.hasNextPage ?? false,
49
+ hasPreviousPage: raw.pageInfo.hasPreviousPage ?? false,
50
+ startCursor: raw.pageInfo.startCursor ?? null,
51
+ endCursor: raw.pageInfo.endCursor ?? null,
52
+ }
53
+ : null,
54
+ totalCount: raw.totalCount ?? null,
55
+ };
56
+ }